diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 92839eb7..e8b98a39 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -25,6 +25,29 @@ std::unique_ptr PageLine::deserialize(FsFile& file) { return std::unique_ptr(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::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)); +} + 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(TAG_PageLine)); + // Use getTag() method to determine type + serialization::writePod(file, static_cast(el->getTag())); + if (!el->serialize(file)) { return false; } @@ -59,6 +83,9 @@ std::unique_ptr 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; diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 20061941..69855844 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -4,10 +4,12 @@ #include #include +#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 deserialize(FsFile& file); }; +// New PageImage class +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); +}; + class Page { public: // the list of block index and line numbers on this page diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index cf67108b..77494556 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -177,7 +177,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c std::vector 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) { lut.emplace_back(this->onPageComplete(std::move(page))); }, popupFn); Hyphenator::setPreferredLanguage(epub->getLanguage()); diff --git a/lib/Epub/Epub/blocks/ImageBlock.cpp b/lib/Epub/Epub/blocks/ImageBlock.cpp new file mode 100644 index 00000000..b420978b --- /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 { + 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::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..8331dbc8 --- /dev/null +++ b/lib/Epub/Epub/blocks/ImageBlock.h @@ -0,0 +1,32 @@ +#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; + + 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 deserialize(FsFile& file); + + private: + std::string imagePath; + int16_t width; + int16_t height; +}; 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/FramebufferWriter.cpp b/lib/Epub/Epub/converters/FramebufferWriter.cpp new file mode 100644 index 00000000..8db13e50 --- /dev/null +++ b/lib/Epub/Epub/converters/FramebufferWriter.cpp @@ -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); + } +} \ No newline at end of file diff --git a/lib/Epub/Epub/converters/FramebufferWriter.h b/lib/Epub/Epub/converters/FramebufferWriter.h new file mode 100644 index 00000000..5fa592a4 --- /dev/null +++ b/lib/Epub/Epub/converters/FramebufferWriter.h @@ -0,0 +1,19 @@ +#pragma once +#include + +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 +}; \ No newline at end of file diff --git a/lib/Epub/Epub/converters/ImageDecoderFactory.cpp b/lib/Epub/Epub/converters/ImageDecoderFactory.cpp new file mode 100644 index 00000000..966c31d0 --- /dev/null +++ b/lib/Epub/Epub/converters/ImageDecoderFactory.cpp @@ -0,0 +1,60 @@ +#include "ImageDecoderFactory.h" + +#include + +#include +#include +#include + +#include "JpegToFramebufferConverter.h" +#include "PngToFramebufferConverter.h" + +std::unique_ptr ImageDecoderFactory::jpegDecoder = nullptr; +std::unique_ptr ImageDecoderFactory::pngDecoder = nullptr; +bool ImageDecoderFactory::initialized = false; + +void ImageDecoderFactory::initialize() { + if (initialized) return; + + jpegDecoder = std::unique_ptr(new JpegToFramebufferConverter()); + pngDecoder = std::unique_ptr(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 ImageDecoderFactory::getSupportedFormats() { + std::vector formats; + formats.push_back(".jpg"); + formats.push_back(".jpeg"); + formats.push_back(".png"); + return formats; +} diff --git a/lib/Epub/Epub/converters/ImageDecoderFactory.h b/lib/Epub/Epub/converters/ImageDecoderFactory.h new file mode 100644 index 00000000..27c7a14a --- /dev/null +++ b/lib/Epub/Epub/converters/ImageDecoderFactory.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include +#include + +#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 getSupportedFormats(); + + private: + static std::unique_ptr jpegDecoder; + static std::unique_ptr pngDecoder; + static bool initialized; +}; diff --git a/lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp new file mode 100644 index 00000000..cbf55f57 --- /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 > 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()); +} \ No newline at end of file diff --git a/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h new file mode 100644 index 00000000..92dc474a --- /dev/null +++ b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h @@ -0,0 +1,41 @@ +#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; + 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); +}; \ No newline at end of file diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp new file mode 100644 index 00000000..fbb3e96b --- /dev/null +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -0,0 +1,286 @@ +#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 (!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(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"); +} diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.h b/lib/Epub/Epub/converters/JpegToFramebufferConverter.h new file mode 100644 index 00000000..51eb9355 --- /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); + } + + 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); +}; diff --git a/lib/Epub/Epub/converters/PixelCache.h b/lib/Epub/Epub/converters/PixelCache.h new file mode 100644 index 00000000..9a20b0ff --- /dev/null +++ b/lib/Epub/Epub/converters/PixelCache.h @@ -0,0 +1,83 @@ +#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) {} + + 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; + } + } +}; diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp new file mode 100644 index 00000000..16cbec63 --- /dev/null +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -0,0 +1,298 @@ +#include "PngToFramebufferConverter.h" + +#include +#include +#include +#include +#include + +#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(handle); + if (f) { + f->close(); + delete f; + } +} + +static 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); +} + +static int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) { + FsFile* f = reinterpret_cast(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; +} + +// 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. +static 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; + } +} + +// Stack buffer for grayscale line conversion (max width from PNGdec) +static uint8_t grayLineBuffer[PNG_MAX_BUFFERED_PIXELS / 2]; + +int pngDrawCallback(PNGDRAW* pDraw) { + PngContext* ctx = reinterpret_cast(pDraw->pUser); + if (!ctx || !ctx->config || !ctx->renderer) 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, 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 = 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; +} + +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); + } + + // 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; + 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 - 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) const { + 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..1083d864 --- /dev/null +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +#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"; } +}; \ No newline at end of file diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index ac1f537f..2fd6d1e2 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -5,7 +5,10 @@ #include #include +#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]); @@ -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(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(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)) { diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 38202e6e..5f160864 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -7,14 +7,17 @@ #include #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; const std::string& filepath; GfxRenderer& renderer; std::function)> completePageFn; @@ -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, 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 std::function& popupFn = nullptr) - : filepath(filepath), + : epub(epub), + filepath(filepath), renderer(renderer), fontId(fontId), lineCompression(lineCompression), diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 86ddc8fc..c6f717b6 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -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; diff --git a/platformio.ini b/platformio.ini index e8574470..4ea5ad7a 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..adfce18b --- /dev/null +++ b/scripts/generate_test_epub.py @@ -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 = ''' + + + + +''' + 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) + + # 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", """ +

    This EPUB tests JPEG image rendering.

    +

    Navigate through chapters to verify each test case.

    +

    Test Plan:

    +
      +
    • Grayscale rendering (4 levels)
    • +
    • Image centering
    • +
    • Large image scaling
    • +
    • Cache performance
    • +
    +"""), []), + ("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. Gradient", make_chapter("Gradient Test", """ +

    Verify gradient quantizes to 4 bands.

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

    Verify image is centered horizontally.

    +Centering test +"""), [('centering_test.jpg', images['centering_test.jpg'])]), + ("5. 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'])]), + ("6. 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'])]), + ("7. 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.

    +

    Test Plan:

    +
      +
    • PNG decoding (no crash)
    • +
    • Grayscale rendering (4 levels)
    • +
    • Image centering
    • +
    • Large image scaling
    • +
    +"""), []), + ("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. Gradient", make_chapter("Gradient Test", """ +

    Verify gradient quantizes to 4 bands.

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

    Verify image is centered horizontally.

    +Centering test +"""), [('centering_test.png', images['centering_test.png'])]), + ("5. 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'])]), + ("6. 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'])]), + ("7. 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/test/epubs/test_jpeg_images.epub b/test/epubs/test_jpeg_images.epub new file mode 100644 index 00000000..24d31e1e Binary files /dev/null and b/test/epubs/test_jpeg_images.epub differ diff --git a/test/epubs/test_mixed_images.epub b/test/epubs/test_mixed_images.epub new file mode 100644 index 00000000..695b6409 Binary files /dev/null and b/test/epubs/test_mixed_images.epub differ diff --git a/test/epubs/test_png_images.epub b/test/epubs/test_png_images.epub new file mode 100644 index 00000000..766e8a1e Binary files /dev/null and b/test/epubs/test_png_images.epub differ