From 19004eefaae8cdb7a08716be62dd322cd141c8d6 Mon Sep 17 00:00:00 2001 From: cottongin Date: Sun, 15 Feb 2026 17:29:39 -0500 Subject: [PATCH] feat: Add EPUB embedded image support (JPEG/PNG) Cherry-pick merge from pablohc/crosspoint-reader@2d8cbcf, based on upstream PR #556 by martinbrook with pablohc's refresh optimization. - Add JPEG decoder (picojpeg) and PNG decoder (PNGdec) with 4-level grayscale Bayer dithering for e-ink display - Add pixel caching system (.pxc files) for fast image re-rendering - Integrate image extraction from EPUB HTML parser ( tag support) - Add ImageBlock/PageImage types with serialization support - Add image-aware refresh optimization (double FAST_REFRESH technique) - Add experimental displayWindow() partial refresh support - Bump section cache version 12->13 to invalidate stale caches - Resolve TAG_PageImage=3 to avoid conflict with mod's TAG_PageTableRow=2 Co-authored-by: Cursor --- lib/Epub/Epub/Page.cpp | 77 +++ lib/Epub/Epub/Page.h | 30 ++ lib/Epub/Epub/Section.cpp | 11 +- lib/Epub/Epub/blocks/Block.h | 2 +- lib/Epub/Epub/blocks/ImageBlock.cpp | 175 ++++++ lib/Epub/Epub/blocks/ImageBlock.h | 31 ++ lib/Epub/Epub/blocks/TextBlock.h | 1 - lib/Epub/Epub/converters/DitherUtils.h | 40 ++ .../Epub/converters/ImageDecoderFactory.cpp | 42 ++ .../Epub/converters/ImageDecoderFactory.h | 20 + .../converters/ImageToFramebufferDecoder.cpp | 18 + .../converters/ImageToFramebufferDecoder.h | 40 ++ .../converters/JpegToFramebufferConverter.cpp | 298 +++++++++++ .../converters/JpegToFramebufferConverter.h | 24 + lib/Epub/Epub/converters/PixelCache.h | 85 +++ .../converters/PngToFramebufferConverter.cpp | 364 +++++++++++++ .../converters/PngToFramebufferConverter.h | 17 + .../Epub/parsers/ChapterHtmlSlimParser.cpp | 137 ++++- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 20 +- lib/GfxRenderer/GfxRenderer.cpp | 17 + lib/GfxRenderer/GfxRenderer.h | 4 +- lib/hal/HalDisplay.cpp | 7 + lib/hal/HalDisplay.h | 4 + platformio.ini | 4 + scripts/generate_test_epub.py | 501 ++++++++++++++++++ src/activities/reader/EpubReaderActivity.cpp | 45 ++ 26 files changed, 1984 insertions(+), 30 deletions(-) create mode 100644 lib/Epub/Epub/blocks/ImageBlock.cpp create mode 100644 lib/Epub/Epub/blocks/ImageBlock.h create mode 100644 lib/Epub/Epub/converters/DitherUtils.h create mode 100644 lib/Epub/Epub/converters/ImageDecoderFactory.cpp create mode 100644 lib/Epub/Epub/converters/ImageDecoderFactory.h create mode 100644 lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp create mode 100644 lib/Epub/Epub/converters/ImageToFramebufferDecoder.h create mode 100644 lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp create mode 100644 lib/Epub/Epub/converters/JpegToFramebufferConverter.h create mode 100644 lib/Epub/Epub/converters/PixelCache.h create mode 100644 lib/Epub/Epub/converters/PngToFramebufferConverter.cpp create mode 100644 lib/Epub/Epub/converters/PngToFramebufferConverter.h create mode 100644 scripts/generate_test_epub.py diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 29ed3a5b..781b978b 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -34,6 +34,33 @@ std::unique_ptr PageLine::deserialize(FsFile& file) { return std::unique_ptr(new PageLine(std::move(tb), xPos, yPos)); } +// --------------------------------------------------------------------------- +// PageImage +// --------------------------------------------------------------------------- + +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::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(new PageImage(std::move(ib), xPos, yPos)); +} + // --------------------------------------------------------------------------- // PageTableRow // --------------------------------------------------------------------------- @@ -183,6 +210,9 @@ std::unique_ptr Page::deserialize(FsFile& file) { return nullptr; } page->elements.push_back(std::move(tr)); + } else if (tag == TAG_PageImage) { + auto pi = PageImage::deserialize(file); + page->elements.push_back(std::move(pi)); } else { LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag); return nullptr; @@ -191,3 +221,50 @@ std::unique_ptr Page::deserialize(FsFile& file) { return page; } + +bool Page::getImageBoundingBox(int& outX, int& outY, int& outWidth, int& outHeight) const { + bool firstImage = true; + for (const auto& el : elements) { + if (el->getTag() == TAG_PageImage) { + PageImage* pi = static_cast(el.get()); + ImageBlock* ib = pi->getImageBlock(); + + if (firstImage) { + // Initialize with first image bounds + outX = pi->xPos; + outY = pi->yPos; + outWidth = ib->getWidth(); + outHeight = ib->getHeight(); + firstImage = false; + } else { + // Expand bounding box to include this image + int imgX = pi->xPos; + int imgY = pi->yPos; + int imgW = ib->getWidth(); + int imgH = ib->getHeight(); + + // Expand right boundary + if (imgX + imgW > outX + outWidth) { + outWidth = (imgX + imgW) - outX; + } + // Expand left boundary + if (imgX < outX) { + int oldRight = outX + outWidth; + outX = imgX; + outWidth = oldRight - outX; + } + // Expand bottom boundary + if (imgY + imgH > outY + outHeight) { + outHeight = (imgY + imgH) - outY; + } + // Expand top boundary + if (imgY < outY) { + int oldBottom = outY + outHeight; + outY = imgY; + outHeight = oldBottom - outY; + } + } + } + } + return !firstImage; // Return true if at least one image was found +} diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 7fc009bf..504de418 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -1,14 +1,17 @@ #pragma once #include +#include #include #include +#include "blocks/ImageBlock.h" #include "blocks/TextBlock.h" enum PageElementTag : uint8_t { TAG_PageLine = 1, TAG_PageTableRow = 2, + TAG_PageImage = 3, }; // represents something that has been added to a page @@ -67,6 +70,22 @@ class PageTableRow final : public PageElement { static std::unique_ptr deserialize(FsFile& file); }; +// An image element on a page +class PageImage final : public PageElement { + std::shared_ptr imageBlock; + + public: + PageImage(std::shared_ptr block, const int16_t xPos, const int16_t yPos) + : PageElement(xPos, yPos), imageBlock(std::move(block)) {} + void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; + bool serialize(FsFile& file) override; + PageElementTag getTag() const override { return TAG_PageImage; } + static std::unique_ptr deserialize(FsFile& file); + + // Helper to get image block dimensions (needed for bounding box calculation) + ImageBlock* getImageBlock() const { return imageBlock.get(); } +}; + class Page { public: // the list of block index and line numbers on this page @@ -74,4 +93,15 @@ class Page { void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; bool serialize(FsFile& file) const; static std::unique_ptr deserialize(FsFile& file); + + // Check if page contains any images (used to force full refresh) + bool hasImages() const { + return std::any_of(elements.begin(), elements.end(), + [](const std::shared_ptr& el) { return el->getTag() == TAG_PageImage; }); + } + + // Get the bounding box of all images on this page. + // Returns true if page has images and fills out the bounding box coordinates. + // If no images, returns false. + bool getImageBoundingBox(int& outX, int& outY, int& outWidth, int& outHeight) const; }; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 5039c57b..30677e83 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -9,7 +9,7 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 12; +constexpr uint8_t SECTION_FILE_VERSION = 13; constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint32_t); @@ -181,11 +181,16 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c viewportHeight, hyphenationEnabled, embeddedStyle); std::vector lut = {}; + // Derive the content base directory and image cache path prefix for the parser + size_t lastSlash = localPath.find_last_of('/'); + std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : ""; + std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_"; + 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) { lut.emplace_back(this->onPageComplete(std::move(page))); }, - embeddedStyle, popupFn, embeddedStyle ? epub->getCssParser() : nullptr); + embeddedStyle, contentBase, imageBasePath, popupFn, embeddedStyle ? epub->getCssParser() : nullptr); Hyphenator::setPreferredLanguage(epub->getLanguage()); success = visitor.parseAndBuildPages(); diff --git a/lib/Epub/Epub/blocks/Block.h b/lib/Epub/Epub/blocks/Block.h index d83e7710..9601a35a 100644 --- a/lib/Epub/Epub/blocks/Block.h +++ b/lib/Epub/Epub/blocks/Block.h @@ -8,7 +8,7 @@ typedef enum { TEXT_BLOCK, IMAGE_BLOCK } BlockType; class Block { public: virtual ~Block() = default; - virtual void layout(GfxRenderer& renderer) = 0; + virtual BlockType getType() = 0; virtual bool isEmpty() = 0; virtual void finish() {} diff --git a/lib/Epub/Epub/blocks/ImageBlock.cpp b/lib/Epub/Epub/blocks/ImageBlock.cpp new file mode 100644 index 00000000..17605b12 --- /dev/null +++ b/lib/Epub/Epub/blocks/ImageBlock.cpp @@ -0,0 +1,175 @@ +#include "ImageBlock.h" + +#include +#include +#include +#include +#include + +#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 { return Storage.exists(imagePath.c_str()); } + +namespace { + +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"; +} + +bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth, + int expectedHeight) { + FsFile cacheFile; + if (!Storage.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; +} + +} // namespace + +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 (!Storage.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.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches + 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::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(new ImageBlock(path, w, h)); +} diff --git a/lib/Epub/Epub/blocks/ImageBlock.h b/lib/Epub/Epub/blocks/ImageBlock.h new file mode 100644 index 00000000..dd37eddf --- /dev/null +++ b/lib/Epub/Epub/blocks/ImageBlock.h @@ -0,0 +1,31 @@ +#pragma once +#include + +#include +#include + +#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; + + 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 deserialize(FsFile& file); + + private: + std::string imagePath; + int16_t width; + int16_t height; +}; diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index f506a036..4ca3d1e7 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -31,7 +31,6 @@ class TextBlock final : public Block { const std::vector& getWordXpos() const { return wordXpos; } const std::vector& getWordStyles() const { return wordStyles; } bool isEmpty() override { return words.empty(); } - void layout(GfxRenderer& renderer) override {}; // given a renderer works out where to break the words into lines void render(const GfxRenderer& renderer, int fontId, int x, int y) const; BlockType getType() override { return TEXT_BLOCK; } diff --git a/lib/Epub/Epub/converters/DitherUtils.h b/lib/Epub/Epub/converters/DitherUtils.h new file mode 100644 index 00000000..ec14a332 --- /dev/null +++ b/lib/Epub/Epub/converters/DitherUtils.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +// 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); + } +} diff --git a/lib/Epub/Epub/converters/ImageDecoderFactory.cpp b/lib/Epub/Epub/converters/ImageDecoderFactory.cpp new file mode 100644 index 00000000..43b01626 --- /dev/null +++ b/lib/Epub/Epub/converters/ImageDecoderFactory.cpp @@ -0,0 +1,42 @@ +#include "ImageDecoderFactory.h" + +#include + +#include +#include + +#include "JpegToFramebufferConverter.h" +#include "PngToFramebufferConverter.h" + +std::unique_ptr ImageDecoderFactory::jpegDecoder = nullptr; +std::unique_ptr ImageDecoderFactory::pngDecoder = nullptr; + +ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& imagePath) { + 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 (JpegToFramebufferConverter::supportsFormat(ext)) { + if (!jpegDecoder) { + jpegDecoder.reset(new JpegToFramebufferConverter()); + } + return jpegDecoder.get(); + } else if (PngToFramebufferConverter::supportsFormat(ext)) { + if (!pngDecoder) { + pngDecoder.reset(new PngToFramebufferConverter()); + } + 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; } diff --git a/lib/Epub/Epub/converters/ImageDecoderFactory.h b/lib/Epub/Epub/converters/ImageDecoderFactory.h new file mode 100644 index 00000000..4a79774f --- /dev/null +++ b/lib/Epub/Epub/converters/ImageDecoderFactory.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include +#include + +#include "ImageToFramebufferDecoder.h" + +class JpegToFramebufferConverter; +class PngToFramebufferConverter; + +class ImageDecoderFactory { + public: + // Returns non-owning pointer - factory owns the decoder lifetime + static ImageToFramebufferDecoder* getDecoder(const std::string& imagePath); + static bool isFormatSupported(const std::string& imagePath); + + private: + static std::unique_ptr jpegDecoder; + static std::unique_ptr pngDecoder; +}; diff --git a/lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp new file mode 100644 index 00000000..0efa1c11 --- /dev/null +++ b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp @@ -0,0 +1,18 @@ +#include "ImageToFramebufferDecoder.h" + +#include +#include + +bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) { + if (width * height > MAX_SOURCE_PIXELS) { + Serial.printf("[%lu] [IMG] Image too large (%dx%d = %d pixels %s), max supported: %d pixels\n", millis(), width, + height, width * height, format.c_str(), MAX_SOURCE_PIXELS); + 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()); +} diff --git a/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h new file mode 100644 index 00000000..d9501a54 --- /dev/null +++ b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h @@ -0,0 +1,40 @@ +#pragma once +#include + +#include +#include + +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; + bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation) + std::string cachePath; // If non-empty, decoder will write pixel cache to this path +}; + +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 const char* getFormatName() const = 0; + + protected: + // Size validation helpers + static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536 + + bool validateImageDimensions(int width, int height, const std::string& format); + void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath); +}; diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp new file mode 100644 index 00000000..e0c52ef5 --- /dev/null +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -0,0 +1,298 @@ +#include "JpegToFramebufferConverter.h" + +#include +#include +#include +#include +#include + +#include +#include + +#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 (!Storage.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 (!Storage.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 output dimensions + int destWidth, destHeight; + float scale; + + if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) { + // Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes) + destWidth = config.maxWidth; + destHeight = config.maxHeight; + scale = (float)destWidth / imageInfo.m_width; + } else { + // 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; + scale = (scaleX < scaleY) ? scaleX : scaleY; + if (scale > 1.0f) scale = 1.0f; + + destWidth = (int)(imageInfo.m_width * scale); + 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(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) { + std::string ext = extension; + for (auto& c : ext) { + c = tolower(c); + } + return (ext == ".jpg" || ext == ".jpeg"); +} diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.h b/lib/Epub/Epub/converters/JpegToFramebufferConverter.h new file mode 100644 index 00000000..d1970e1f --- /dev/null +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.h @@ -0,0 +1,24 @@ +#pragma once +#include + +#include + +#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); + } + + static bool supportsFormat(const std::string& extension); + 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); +}; diff --git a/lib/Epub/Epub/converters/PixelCache.h b/lib/Epub/Epub/converters/PixelCache.h new file mode 100644 index 00000000..b3cf91b3 --- /dev/null +++ b/lib/Epub/Epub/converters/PixelCache.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +// 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) {} + PixelCache(const PixelCache&) = delete; + PixelCache& operator=(const PixelCache&) = delete; + + 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 (!Storage.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; + } + } +}; diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp new file mode 100644 index 00000000..d76dc1da --- /dev/null +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -0,0 +1,364 @@ +#include "PngToFramebufferConverter.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include "DitherUtils.h" +#include "PixelCache.h" + +namespace { + +// 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; + + uint8_t* grayLineBuffer; + + PngContext() + : renderer(nullptr), + config(nullptr), + screenWidth(0), + screenHeight(0), + scale(1.0f), + srcWidth(0), + srcHeight(0), + dstWidth(0), + dstHeight(0), + lastDstY(-1), + caching(false), + grayLineBuffer(nullptr) {} +}; + +// File I/O callbacks use pFile->fHandle to access the FsFile*, +// avoiding the need for global file state. +void* pngOpenWithHandle(const char* filename, int32_t* size) { + FsFile* f = new FsFile(); + if (!Storage.openFileForRead("PNG", std::string(filename), *f)) { + delete f; + return nullptr; + } + *size = f->size(); + return f; +} + +void pngCloseWithHandle(void* handle) { + FsFile* f = reinterpret_cast(handle); + if (f) { + f->close(); + delete f; + } +} + +int32_t pngReadWithHandle(PNGFILE* pFile, uint8_t* pBuf, int32_t len) { + FsFile* f = reinterpret_cast(pFile->fHandle); + if (!f) return 0; + return f->read(pBuf, len); +} + +int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) { + FsFile* f = reinterpret_cast(pFile->fHandle); + if (!f) return -1; + return f->seek(pos); +} + +// The PNG decoder (PNGdec) is ~42 KB due to internal zlib decompression buffers. +// We heap-allocate it on demand rather than using a static instance, so this memory +// is only consumed while actually decoding/querying PNG images. This is critical on +// the ESP32-C3 where total RAM is ~320 KB. +constexpr size_t PNG_DECODER_APPROX_SIZE = 44 * 1024; // ~42 KB + overhead +constexpr size_t MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom + +// Convert entire source line to grayscale with alpha blending to white background. +// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards. +// Processing the whole line at once improves cache locality and reduces per-pixel overhead. +void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette, int hasAlpha) { + switch (pixelType) { + case PNG_PIXEL_GRAYSCALE: + memcpy(grayLine, pPixels, width); + break; + + case PNG_PIXEL_TRUECOLOR: + for (int x = 0; x < width; x++) { + uint8_t* p = &pPixels[x * 3]; + grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); + } + break; + + case PNG_PIXEL_INDEXED: + if (palette) { + if (hasAlpha) { + for (int x = 0; x < width; x++) { + uint8_t idx = pPixels[x]; + uint8_t* p = &palette[idx * 3]; + uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); + uint8_t alpha = palette[768 + idx]; + grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255); + } + } else { + for (int x = 0; x < width; x++) { + uint8_t* p = &palette[pPixels[x] * 3]; + grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); + } + } + } else { + memcpy(grayLine, pPixels, width); + } + break; + + case PNG_PIXEL_GRAY_ALPHA: + for (int x = 0; x < width; x++) { + uint8_t gray = pPixels[x * 2]; + uint8_t alpha = pPixels[x * 2 + 1]; + grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255); + } + break; + + case PNG_PIXEL_TRUECOLOR_ALPHA: + for (int x = 0; x < width; x++) { + uint8_t* p = &pPixels[x * 4]; + uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); + uint8_t alpha = p[3]; + grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255); + } + break; + + default: + memset(grayLine, 128, width); + break; + } +} + +int pngDrawCallback(PNGDRAW* pDraw) { + PngContext* ctx = reinterpret_cast(pDraw->pUser); + if (!ctx || !ctx->config || !ctx->renderer || !ctx->grayLineBuffer) return 0; + + int srcY = pDraw->y; + int srcWidth = ctx->srcWidth; + + // 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; + + // Convert entire source line to grayscale (improves cache locality) + convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->pPalette, + pDraw->iHasAlpha); + + // Render scaled row using Bresenham-style integer stepping (no floating-point division) + int dstWidth = ctx->dstWidth; + int outXBase = ctx->config->x; + int screenWidth = ctx->screenWidth; + bool useDithering = ctx->config->useDithering; + bool caching = ctx->caching; + + int srcX = 0; + int error = 0; + + for (int dstX = 0; dstX < dstWidth; dstX++) { + int outX = outXBase + dstX; + if (outX < screenWidth) { + uint8_t gray = ctx->grayLineBuffer[srcX]; + + uint8_t ditheredGray; + if (useDithering) { + ditheredGray = applyBayerDither4Level(gray, outX, outY); + } else { + ditheredGray = gray / 85; + if (ditheredGray > 3) ditheredGray = 3; + } + drawPixelWithRenderMode(*ctx->renderer, outX, outY, ditheredGray); + if (caching) ctx->cache.setPixel(outX, outY, ditheredGray); + } + + // Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth + error += srcWidth; + while (error >= dstWidth) { + error -= dstWidth; + srcX++; + } + } + + return 1; +} + +} // namespace + +bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) { + size_t freeHeap = ESP.getFreeHeap(); + if (freeHeap < MIN_FREE_HEAP_FOR_PNG) { + Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap, + MIN_FREE_HEAP_FOR_PNG); + return false; + } + + PNG* png = new (std::nothrow) PNG(); + if (!png) { + Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder for dimensions\n", millis()); + return false; + } + + 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); + delete png; + return false; + } + + out.width = png->getWidth(); + out.height = png->getHeight(); + + png->close(); + delete png; + return true; +} + +bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, + const RenderConfig& config) { + Serial.printf("[%lu] [PNG] Decoding PNG: %s\n", millis(), imagePath.c_str()); + + size_t freeHeap = ESP.getFreeHeap(); + if (freeHeap < MIN_FREE_HEAP_FOR_PNG) { + Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap, + MIN_FREE_HEAP_FOR_PNG); + return false; + } + + // Heap-allocate PNG decoder (~42 KB) - freed at end of function + PNG* png = new (std::nothrow) PNG(); + if (!png) { + Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder\n", millis()); + return false; + } + + 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); + delete png; + return false; + } + + if (!validateImageDimensions(png->getWidth(), png->getHeight(), "PNG")) { + png->close(); + delete png; + return false; + } + + // Calculate output dimensions + ctx.srcWidth = png->getWidth(); + ctx.srcHeight = png->getHeight(); + + if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) { + // Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes) + ctx.dstWidth = config.maxWidth; + ctx.dstHeight = config.maxHeight; + ctx.scale = (float)ctx.dstWidth / ctx.srcWidth; + } else { + // Calculate scale factor to fit within maxWidth/maxHeight + 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); + } + + // Allocate grayscale line buffer on demand (~3.2 KB) - freed after decode + const size_t grayBufSize = PNG_MAX_BUFFERED_PIXELS / 2; + ctx.grayLineBuffer = static_cast(malloc(grayBufSize)); + if (!ctx.grayLineBuffer) { + Serial.printf("[%lu] [PNG] Failed to allocate gray line buffer\n", millis()); + png->close(); + delete png; + return false; + } + + // 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; + } + } + + unsigned long decodeStart = millis(); + rc = png->decode(&ctx, 0); + unsigned long decodeTime = millis() - decodeStart; + + free(ctx.grayLineBuffer); + ctx.grayLineBuffer = nullptr; + + if (rc != PNG_SUCCESS) { + Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc); + png->close(); + delete png; + return false; + } + + png->close(); + delete png; + Serial.printf("[%lu] [PNG] PNG decoding complete - render time: %lu ms\n", millis(), decodeTime); + + // 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) { + std::string ext = extension; + for (auto& c : ext) { + c = tolower(c); + } + return (ext == ".png"); +} diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.h b/lib/Epub/Epub/converters/PngToFramebufferConverter.h new file mode 100644 index 00000000..4af51a96 --- /dev/null +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.h @@ -0,0 +1,17 @@ +#pragma once + +#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); + } + + static bool supportsFormat(const std::string& extension); + const char* getFormatName() const override { return "PNG"; } +}; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 29210ba9..560c730c 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1,5 +1,6 @@ #include "ChapterHtmlSlimParser.h" +#include #include #include #include @@ -7,7 +8,10 @@ #include +#include "../../Epub.h" #include "../Page.h" +#include "../converters/ImageDecoderFactory.h" +#include "../converters/ImageToFramebufferDecoder.h" #include "../htmlEntities.h" const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; @@ -367,30 +371,125 @@ 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()) { + LOG_DBG("EHP", "Found image: src=%s", src.c_str()); + + { + // Resolve the image path relative to the HTML file + std::string resolvedPath = FsHelpers::normalisePath(self->contentBase + src); + + // 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->imageBasePath + std::to_string(self->imageCounter++) + ext; + + // Extract image to cache file + FsFile cachedImageFile; + bool extractSuccess = false; + if (Storage.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)) { + LOG_DBG("EHP", "Image dimensions: %dx%d", 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); + + LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", 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) { + LOG_ERR("EHP", "Failed to create new page"); + return; + } + self->currentPageNextY = 0; + } else if (!self->currentPage) { + self->currentPage.reset(new Page()); + if (!self->currentPage) { + LOG_ERR("EHP", "Failed to create initial page"); + return; + } + self->currentPageNextY = 0; + } + + // Create ImageBlock and add to page + auto imageBlock = std::make_shared(cachedImagePath, displayWidth, displayHeight); + if (!imageBlock) { + LOG_ERR("EHP", "Failed to create ImageBlock"); + return; + } + int xPos = (self->viewportWidth - displayWidth) / 2; + auto pageImage = std::make_shared(imageBlock, xPos, self->currentPageNextY); + if (!pageImage) { + LOG_ERR("EHP", "Failed to create PageImage"); + return; + } + self->currentPage->elements.push_back(pageImage); + self->currentPageNextY += displayHeight; + + self->depth += 1; + return; + } else { + LOG_ERR("EHP", "Failed to get image dimensions"); + Storage.remove(cachedImagePath.c_str()); + } + } else { + LOG_ERR("EHP", "Failed to extract image"); + } + } + } + + // Fallback to alt text if image processing fails + if (!alt.empty()) { + alt = "[Image: " + alt + "]"; + self->startNewTextBlock(centeredBlockStyle); + self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); + self->depth += 1; + self->characterData(userData, alt.c_str(), alt.length()); + // Skip any child content (skip until parent as we pre-advanced depth above) + self->skipUntilDepth = self->depth - 1; + return; + } + + // No alt text, skip + self->skipUntilDepth = self->depth; + self->depth += 1; + return; } - - LOG_DBG("EHP", "Image alt: %s", alt.c_str()); - - self->startNewTextBlock(centeredBlockStyle); - self->italicUntilDepth = min(self->italicUntilDepth, self->depth); - // Advance depth before processing character data (like you would for an 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)) { diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index c42b9348..51b65592 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -8,6 +8,7 @@ #include "../ParsedText.h" #include "../TableData.h" +#include "../blocks/ImageBlock.h" #include "../blocks/TextBlock.h" #include "../css/CssParser.h" #include "../css/CssStyle.h" @@ -15,10 +16,12 @@ class Page; class PageTableRow; class GfxRenderer; +class Epub; #define MAX_WORD_SIZE 200 class ChapterHtmlSlimParser { + std::shared_ptr epub; const std::string& filepath; GfxRenderer& renderer; std::function)> completePageFn; @@ -45,6 +48,9 @@ class ChapterHtmlSlimParser { bool hyphenationEnabled; const CssParser* cssParser; bool embeddedStyle; + std::string contentBase; + std::string imageBasePath; + int imageCounter = 0; // Style tracking (replaces depth-based approach) struct StyleStackEntry { @@ -76,15 +82,17 @@ 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, 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)>& completePageFn, - const bool embeddedStyle, const std::function& popupFn = nullptr, + const bool embeddedStyle, const std::string& contentBase, + const std::string& imageBasePath, const std::function& popupFn = nullptr, const CssParser* cssParser = nullptr) - : filepath(filepath), + : epub(epub), + filepath(filepath), renderer(renderer), fontId(fontId), lineCompression(lineCompression), @@ -96,7 +104,9 @@ class ChapterHtmlSlimParser { completePageFn(completePageFn), popupFn(popupFn), cssParser(cssParser), - embeddedStyle(embeddedStyle) {} + embeddedStyle(embeddedStyle), + contentBase(contentBase), + imageBasePath(imageBasePath) {} ~ChapterHtmlSlimParser() = default; bool parseAndBuildPages(); diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index b39de08c..33f70993 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -726,6 +726,23 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const display.displayBuffer(refreshMode, fadingFix); } +// EXPERIMENTAL: Display only a rectangular region with specified refresh mode +void GfxRenderer::displayWindow(int x, int y, int width, int height, + HalDisplay::RefreshMode mode) const { + LOG_DBG("GFX", "Displaying window at (%d,%d) size (%dx%d) with mode %d", x, y, width, height, + static_cast(mode)); + + // Validate bounds + if (x < 0 || y < 0 || x + width > getScreenWidth() || y + height > getScreenHeight()) { + LOG_ERR("GFX", "Window bounds exceed display dimensions!"); + return; + } + + display.displayWindow(static_cast(x), static_cast(y), + static_cast(width), static_cast(height), mode, + fadingFix); +} + std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, const EpdFontFamily::Style style) const { if (!text || maxWidth <= 0) return ""; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 1804c0c3..9758ce61 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -70,7 +70,8 @@ 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, + HalDisplay::RefreshMode mode = HalDisplay::FAST_REFRESH) const; void invertScreen() const; void clearScreen(uint8_t color = 0xFF) const; void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const; @@ -120,6 +121,7 @@ class GfxRenderer { // Grayscale functions void setRenderMode(const RenderMode mode) { this->renderMode = mode; } + RenderMode getRenderMode() const { return renderMode; } void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index 0fafdbb5..4c0bdc27 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -32,6 +32,13 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen); } +// EXPERIMENTAL: Display only a rectangular region +void HalDisplay::displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h, + HalDisplay::RefreshMode mode, bool turnOffScreen) { + (void)mode; // EInkDisplay::displayWindow does not take mode yet + einkDisplay.displayWindow(x, y, w, h, turnOffScreen); +} + void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) { einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index 238832b0..547aa2df 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -34,6 +34,10 @@ class HalDisplay { void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); + // EXPERIMENTAL: Display only a rectangular region + void displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h, + RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); + // Power management void deepSleep(); diff --git a/platformio.ini b/platformio.ini index f1a33574..85f61f0c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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] diff --git a/scripts/generate_test_epub.py b/scripts/generate_test_epub.py new file mode 100644 index 00000000..8cffbb07 --- /dev/null +++ b/scripts/generate_test_epub.py @@ -0,0 +1,501 @@ +#!/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 + + 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 + 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) + + 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. + """ + 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 + 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) + + # Pass/fail + 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_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 = ''' + + + + +''' + 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' ') + epub.writestr(f'OEBPS/images/{img_filename}', img_data) + + # Add chapter + manifest_items.append(f' ') + spine_items.append(f' ') + epub.writestr(f'OEBPS/{chapter_file}', html_content) + + # content.opf + content_opf = f''' + + + test-epub-{title.lower().replace(" ", "-")} + {title} + en + + + +{chr(10).join(manifest_items)} + + +{chr(10).join(spine_items)} + +''' + epub.writestr('OEBPS/content.opf', content_opf) + + # Navigation document + nav_items = '\n'.join([f'
  • {chapters[i][0]}
  • ' + for i in range(len(chapters))]) + nav_xhtml = f''' + + +Navigation + + + +''' + epub.writestr('OEBPS/nav.xhtml', nav_xhtml) + +def make_chapter(title, body_content): + """Create XHTML chapter content.""" + return f''' + + +{title} + +

    {title}

    +{body_content} + +''' + +def main(): + OUTPUT_DIR.mkdir(exist_ok=True) + + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + + print("Generating 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_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_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", """ +

    This EPUB tests JPEG image rendering.

    +

    Navigate through chapters to verify each test case.

    +"""), []), + ("1. JPEG Format", make_chapter("JPEG Format Test", """ +

    Basic JPEG decoding test.

    +JPEG format test +

    If the image above is visible, JPEG decoding works.

    +"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]), + ("2. Grayscale", make_chapter("Grayscale Test", """ +

    Verify 4 distinct gray levels are visible.

    +Grayscale test +"""), [('grayscale_test.jpg', images['grayscale_test.jpg'])]), + ("3. Centering", make_chapter("Centering Test", """ +

    Verify image is centered horizontally.

    +Centering test +"""), [('centering_test.jpg', images['centering_test.jpg'])]), + ("4. Scaling", make_chapter("Scaling Test", """ +

    This image is 1200x1500 pixels - larger than the screen.

    +

    It should be scaled down to fit.

    +Scaling test +"""), [('scaling_test.jpg', images['scaling_test.jpg'])]), + ("5. Cache Test A", make_chapter("Cache Test - Page A", """ +

    First cache test page. Note the load time.

    +Cache test 1 +

    Navigate to next page, then come back.

    +"""), [('cache_test_1.jpg', images['cache_test_1.jpg'])]), + ("6. Cache Test B", make_chapter("Cache Test - Page B", """ +

    Second cache test page.

    +Cache test 2 +

    Navigate back to Page A - it should load faster from cache.

    +"""), [('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", """ +

    This EPUB tests PNG image rendering.

    +

    Navigate through chapters to verify each test case.

    +"""), []), + ("1. PNG Format", make_chapter("PNG Format Test", """ +

    Basic PNG decoding test.

    +PNG format test +

    If the image above is visible and no crash occurred, PNG decoding works.

    +"""), [('png_format.png', images['png_format.png'])]), + ("2. Grayscale", make_chapter("Grayscale Test", """ +

    Verify 4 distinct gray levels are visible.

    +Grayscale test +"""), [('grayscale_test.png', images['grayscale_test.png'])]), + ("3. Centering", make_chapter("Centering Test", """ +

    Verify image is centered horizontally.

    +Centering test +"""), [('centering_test.png', images['centering_test.png'])]), + ("4. Scaling", make_chapter("Scaling Test", """ +

    This image is 1200x1500 pixels - larger than the screen.

    +

    It should be scaled down to fit.

    +Scaling test +"""), [('scaling_test.png', images['scaling_test.png'])]), + ("5. Cache Test A", make_chapter("Cache Test - Page A", """ +

    First cache test page. Note the load time.

    +Cache test 1 +

    Navigate to next page, then come back.

    +"""), [('cache_test_1.png', images['cache_test_1.png'])]), + ("6. Cache Test B", make_chapter("Cache Test - Page B", """ +

    Second cache test page.

    +Cache test 2 +

    Navigate back to Page A - it should load faster from cache.

    +"""), [('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", """ +

    This EPUB contains both JPEG and PNG images.

    +

    Tests format detection and mixed rendering.

    +"""), []), + ("1. JPEG Image", make_chapter("JPEG in Mixed EPUB", """ +

    This is a JPEG image:

    +JPEG +"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]), + ("2. PNG Image", make_chapter("PNG in Mixed EPUB", """ +

    This is a PNG image:

    +PNG +"""), [('png_format.png', images['png_format.png'])]), + ("3. Both Formats", make_chapter("Both Formats on One Page", """ +

    JPEG image:

    +JPEG grayscale +

    PNG image:

    +PNG grayscale +

    Both should render with proper grayscale.

    +"""), [('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() diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3ad7e656..36687b65 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -22,6 +22,11 @@ #include "util/BookmarkStore.h" #include "util/Dictionary.h" +// Image refresh optimization strategy: +// 0 = Use double FAST_REFRESH technique (default, feels snappier) +// 1 = Use displayWindow() for partial refresh (experimental) +#define USE_IMAGE_DOUBLE_FAST_REFRESH 0 + namespace { // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() constexpr unsigned long skipChapterMs = 700; @@ -972,6 +977,14 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC void EpubReaderActivity::renderContents(std::unique_ptr page, const int orientedMarginTop, const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) { + // Determine if this page needs special image handling + bool pageHasImages = page->hasImages(); + bool useAntiAliasing = SETTINGS.textAntiAliasing; + + // Force half refresh for pages with images when anti-aliasing is on, + // as grayscale tones require half refresh to display correctly + bool forceFullRefresh = pageHasImages && useAntiAliasing; + page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // Draw bookmark ribbon indicator in top-right corner if current page is bookmarked @@ -990,10 +1003,42 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or } renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); + + // Check if half-refresh is needed (either entering Reader or pages counter reached) if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); + } else if (forceFullRefresh) { + // OPTIMIZATION: For image pages with anti-aliasing, use fast double-refresh technique + // to reduce perceived lag. Only when pagesUntilFullRefresh > 1 (screen already clean). + int imgX, imgY, imgW, imgH; + if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) { + int screenX = imgX + orientedMarginLeft; + int screenY = imgY + orientedMarginTop; + LOG_DBG("ERS", "Image page: fast double-refresh (page bbox: %d,%d %dx%d, screen: %d,%d %dx%d)", + imgX, imgY, imgW, imgH, screenX, screenY, imgW, imgH); + +#if USE_IMAGE_DOUBLE_FAST_REFRESH == 0 + // Method A: Fill blank area + two FAST_REFRESH operations + renderer.fillRect(screenX, screenY, imgW, imgH, false); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); +#else + // Method B (experimental): Use displayWindow() for partial refresh + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); + renderer.displayWindow(screenX, screenY, imgW, imgH, HalDisplay::FAST_REFRESH); +#endif + } else { + LOG_DBG("ERS", "Image page but no bbox, using standard half refresh"); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + } + pagesUntilFullRefresh--; } else { + // Normal page without images, or images without anti-aliasing renderer.displayBuffer(); pagesUntilFullRefresh--; }