diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 92839eb..e8b98a3 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 9245cc9..969bb73 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,12 +33,26 @@ 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); // Getter for word selection support const std::shared_ptr& getTextBlock() const { return block; } }; +// 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 91dda07..ee8b91a 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -195,7 +195,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) { // Capture content offset before processing diff --git a/lib/Epub/Epub/blocks/ImageBlock.cpp b/lib/Epub/Epub/blocks/ImageBlock.cpp new file mode 100644 index 0000000..7c52dd8 --- /dev/null +++ b/lib/Epub/Epub/blocks/ImageBlock.cpp @@ -0,0 +1,173 @@ +#include "ImageBlock.h" + +#include +#include +#include +#include +#include + +#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; + renderer.drawPixel(x + col, destY, pixelValue < 2); + } + } + + 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 0000000..8331dbc --- /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/FramebufferWriter.cpp b/lib/Epub/Epub/converters/FramebufferWriter.cpp new file mode 100644 index 0000000..8db13e5 --- /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 0000000..5fa592a --- /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 0000000..8b26b6e --- /dev/null +++ b/lib/Epub/Epub/converters/ImageDecoderFactory.cpp @@ -0,0 +1,68 @@ +#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) { + if (jpegDecoder && jpegDecoder->supportsFormat(imagePath)) { + return true; + } + if (pngDecoder && pngDecoder->supportsFormat(imagePath)) { + return true; + } + return false; +} + +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 0000000..27c7a14 --- /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 0000000..cbf55f5 --- /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 0000000..92dc474 --- /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 0000000..b46cb7f --- /dev/null +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -0,0 +1,392 @@ +#include "JpegToFramebufferConverter.h" + +#include +#include +#include +#include +#include + +#include +#include + +struct JpegContext { + FsFile& file; + uint8_t buffer[512]; + size_t bufferPos; + size_t bufferFilled; + JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {} +}; + +// Cache buffer for storing 2-bit pixels during decode +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) {} + + 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 = bytesPerRow * h; + buffer = (uint8_t*)malloc(bufferSize); + if (buffer) { + memset(buffer, 0, bufferSize); + Serial.printf("[%lu] [JPG] 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] [JPG] 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] [JPG] 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; + } + } +}; + +static int16_t ditherErrors[512][3]; + +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; +} + +uint8_t JpegToFramebufferConverter::applyAtkinsonDithering(uint8_t gray, int x, int y, int width) { + int16_t error = gray - (gray < 128 ? 0 : 255); + uint8_t newGray = gray - error; + + int8_t fraction = error >> 3; + + if (x + 1 < width && y + 1 < 512) ditherErrors[y + 1][(x + 1) % 3] += fraction; + if (x + 2 < width && y + 1 < 512) ditherErrors[y + 1][(x + 2) % 3] += fraction; + if (x + 1 < width) ditherErrors[y][(x + 1) % 3] += fraction; + if (x + 2 < width) ditherErrors[y][(x + 2) % 3] += fraction; + if (x - 1 >= 0 && x + 1 < width && y + 1 < 512) ditherErrors[y + 1][(x - 1 + 1) % 3] += fraction; + if (x - 1 >= 0 && y + 1 < 512) ditherErrors[y + 1][(x - 1) % 3] += fraction; + if (x + 1 < width && y + 2 < 512) ditherErrors[y + 2][(x + 1) % 3] += fraction; + + int16_t adjustedGray = newGray + ditherErrors[y][x % 3]; + if (adjustedGray < 0) adjustedGray = 0; + if (adjustedGray > 255) adjustedGray = 255; + + uint8_t outputGray; + if (adjustedGray < 64) { + outputGray = 0; + } else if (adjustedGray < 128) { + outputGray = 1; + } else if (adjustedGray < 192) { + outputGray = 2; + } else { + outputGray = 3; + } + + ditherErrors[y][x % 3] = adjustedGray - (outputGray * 85); + + return outputGray; +} + +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; + } + + memset(ditherErrors, 0, sizeof(ditherErrors)); + + 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 ? applyAtkinsonDithering(gray, destX, destY, screenWidth) : gray / 85; + if (dithered > 3) dithered = 3; + renderer.drawPixel(destX, destY, dithered < 2); + 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 ? applyAtkinsonDithering(gray, destX, destY, screenWidth) : gray / 85; + if (dithered > 3) dithered = 3; + renderer.drawPixel(destX, destY, dithered < 2); + 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 ? applyAtkinsonDithering(gray, destX, destY, screenWidth) : gray / 85; + if (dithered > 3) dithered = 3; + renderer.drawPixel(destX, destY, dithered < 2); + 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 ? applyAtkinsonDithering(gray, destX, destY, screenWidth) : gray / 85; + if (dithered > 3) dithered = 3; + renderer.drawPixel(destX, destY, dithered < 2); + 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 ? applyAtkinsonDithering(gray, destX, destY, screenWidth) : gray / 85; + if (dithered > 3) dithered = 3; + renderer.drawPixel(destX, destY, dithered < 2); + 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 0000000..edf9dce --- /dev/null +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.h @@ -0,0 +1,25 @@ +#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: + uint8_t applyAtkinsonDithering(uint8_t gray, int x, int y, int width); + 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/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp new file mode 100644 index 0000000..5cc17ed --- /dev/null +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -0,0 +1,354 @@ +#include "PngToFramebufferConverter.h" + +#include +#include +#include +#include +#include + +static FsFile* gPngFile = nullptr; + +static void* pngOpenForDims(const char* filename, int32_t* size) { return gPngFile; } + +static void pngCloseForDims(void* handle) {} + +static int32_t pngReadForDims(PNGFILE* pFile, uint8_t* pBuf, int32_t len) { + if (!gPngFile) return 0; + return gPngFile->read(pBuf, len); +} + +static int32_t pngSeekForDims(PNGFILE* pFile, int32_t pos) { + if (!gPngFile) return -1; + return gPngFile->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) { + FsFile file; + if (!SdMan.openFileForRead("PNG", imagePath, file)) { + Serial.printf("[%lu] [PNG] Failed to open file for dimensions: %s\n", millis(), imagePath.c_str()); + return false; + } + + gPngFile = &file; + + int rc = png.open(imagePath.c_str(), pngOpenForDims, pngCloseForDims, pngReadForDims, pngSeekForDims, nullptr); + + if (rc != 0) { + Serial.printf("[%lu] [PNG] Failed to open PNG for dimensions: %d\n", millis(), rc); + file.close(); + gPngFile = nullptr; + return false; + } + + out.width = png.getWidth(); + out.height = png.getHeight(); + + png.close(); + file.close(); + gPngFile = nullptr; + return true; +} +static GfxRenderer* gRenderer = nullptr; +static const RenderConfig* gConfig = nullptr; +static int gScreenWidth = 0; +static int gScreenHeight = 0; +static int16_t ditherErrors[2048][3]; +static FsFile* pngFile = nullptr; + +// Scaling state for PNG +static float gScale = 1.0f; +static int gSrcWidth = 0; +static int gSrcHeight = 0; +static int gDstWidth = 0; +static int gDstHeight = 0; +static int gLastDstY = -1; // Track last rendered destination Y to avoid duplicates + +// Pixel cache for PNG (uses scaled dimensions) +static uint8_t* gCacheBuffer = nullptr; +static int gCacheWidth = 0; +static int gCacheHeight = 0; +static int gCacheBytesPerRow = 0; +static int gCacheOriginX = 0; +static int gCacheOriginY = 0; + +static void cacheSetPixel(int screenX, int screenY, uint8_t value) { + if (!gCacheBuffer) return; + int localX = screenX - gCacheOriginX; + int localY = screenY - gCacheOriginY; + if (localX < 0 || localX >= gCacheWidth || localY < 0 || localY >= gCacheHeight) return; + + int byteIdx = localY * gCacheBytesPerRow + localX / 4; + int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7 + gCacheBuffer[byteIdx] = (gCacheBuffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift); +} + +static uint8_t applyAtkinsonDithering(uint8_t gray, int x, int y, int width) { + int16_t error = gray - (gray < 128 ? 0 : 255); + uint8_t newGray = gray - error; + + int8_t fraction = error >> 3; + + if (x + 1 < width) ditherErrors[y + 1][(x + 1) % 3] += fraction; + if (x + 2 < width) ditherErrors[y + 1][(x + 2) % 3] += fraction; + if (x + 1 < width) ditherErrors[y][(x + 1) % 3] += fraction; + if (x + 2 < width) ditherErrors[y][(x + 2) % 3] += fraction; + if (x - 1 >= 0 && x + 1 < width) ditherErrors[y + 1][(x - 1 + 1) % 3] += fraction; + if (x - 1 >= 0) ditherErrors[y + 1][(x - 1) % 3] += fraction; + if (x + 1 < width) ditherErrors[y + 2][(x + 1) % 3] += fraction; + + int16_t adjustedGray = newGray + ditherErrors[y][x % 3]; + if (adjustedGray < 0) adjustedGray = 0; + if (adjustedGray > 255) adjustedGray = 255; + + uint8_t outputGray; + if (adjustedGray < 64) { + outputGray = 0; + } else if (adjustedGray < 128) { + outputGray = 1; + } else if (adjustedGray < 192) { + outputGray = 2; + } else { + outputGray = 3; + } + + ditherErrors[y][x % 3] = adjustedGray - (outputGray * 85); + + return outputGray; +} + +void* pngOpen(const char* filename, int32_t* size) { + pngFile = new FsFile(); + if (!SdMan.openFileForRead("PNG", std::string(filename), *pngFile)) { + delete pngFile; + pngFile = nullptr; + return nullptr; + } + *size = pngFile->size(); + return pngFile; +} + +void pngClose(void* handle) { + if (pngFile) { + pngFile->close(); + delete pngFile; + pngFile = nullptr; + } +} + +int32_t pngRead(PNGFILE* pFile, uint8_t* pBuf, int32_t len) { + if (!pngFile) return 0; + return pngFile->read(pBuf, len); +} + +int32_t pngSeek(PNGFILE* pFile, int32_t pos) { + if (!pngFile) return -1; + return pngFile->seek(pos); +} + +// Helper to get grayscale from PNG pixel data +static uint8_t getGrayFromPixel(uint8_t* pPixels, int x, int pixelType, uint8_t* palette) { + switch (pixelType) { + case PNG_PIXEL_GRAYSCALE: + return pPixels[x]; + + case PNG_PIXEL_TRUECOLOR: { + uint8_t* p = &pPixels[x * 3]; + return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); + } + + case PNG_PIXEL_INDEXED: { + uint8_t paletteIndex = pPixels[x]; + if (palette) { + uint8_t* p = &palette[paletteIndex * 3]; + return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); + } + return paletteIndex; + } + + case PNG_PIXEL_GRAY_ALPHA: + return pPixels[x * 2]; + + case PNG_PIXEL_TRUECOLOR_ALPHA: { + uint8_t* p = &pPixels[x * 4]; + return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); + } + + default: + return 128; + } +} + +int pngDrawCallback(PNGDRAW* pDraw) { + if (!gConfig || !gRenderer) return 0; + + int srcY = pDraw->y; + uint8_t* pPixels = pDraw->pPixels; + int pixelType = pDraw->iPixelType; + + // Calculate destination Y with scaling + int dstY = (int)(srcY * gScale); + + // Skip if we already rendered this destination row (multiple source rows map to same dest) + if (dstY == gLastDstY) return 1; + gLastDstY = dstY; + + // Check bounds + if (dstY >= gDstHeight) return 1; + + int outY = gConfig->y + dstY; + if (outY >= gScreenHeight) return 1; + + // Render scaled row using nearest-neighbor sampling + for (int dstX = 0; dstX < gDstWidth; dstX++) { + int outX = gConfig->x + dstX; + if (outX >= gScreenWidth) continue; + + // Map destination X back to source X + int srcX = (int)(dstX / gScale); + if (srcX >= gSrcWidth) srcX = gSrcWidth - 1; + + uint8_t gray = getGrayFromPixel(pPixels, srcX, pixelType, pDraw->pPalette); + + uint8_t ditheredGray; + if (gConfig->useDithering) { + ditheredGray = applyAtkinsonDithering(gray, outX, outY, gScreenWidth); + } else { + ditheredGray = gray / 85; + if (ditheredGray > 3) ditheredGray = 3; + } + gRenderer->drawPixel(outX, outY, ditheredGray < 2); + cacheSetPixel(outX, outY, ditheredGray); + } + + return 1; +} + +bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, + const RenderConfig& config) { + Serial.printf("[%lu] [PNG] Decoding PNG: %s\n", millis(), imagePath.c_str()); + + FsFile file; + if (!SdMan.openFileForRead("PNG", imagePath, file)) { + Serial.printf("[%lu] [PNG] Failed to open file: %s\n", millis(), imagePath.c_str()); + return false; + } + + memset(ditherErrors, 0, sizeof(ditherErrors)); + gRenderer = &renderer; + gConfig = &config; + gScreenWidth = renderer.getScreenWidth(); + gScreenHeight = renderer.getScreenHeight(); + + int rc = png.open(imagePath.c_str(), pngOpen, pngClose, pngRead, pngSeek, pngDrawCallback); + if (rc != PNG_SUCCESS) { + Serial.printf("[%lu] [PNG] Failed to open PNG: %d\n", millis(), rc); + file.close(); + gRenderer = nullptr; + gConfig = nullptr; + return false; + } + + if (!validateImageDimensions(png.getWidth(), png.getHeight(), "PNG")) { + png.close(); + file.close(); + gRenderer = nullptr; + gConfig = nullptr; + return false; + } + + // Calculate scale factor to fit within maxWidth x maxHeight + gSrcWidth = png.getWidth(); + gSrcHeight = png.getHeight(); + float scaleX = (float)config.maxWidth / gSrcWidth; + float scaleY = (float)config.maxHeight / gSrcHeight; + gScale = (scaleX < scaleY) ? scaleX : scaleY; + if (gScale > 1.0f) gScale = 1.0f; // Don't upscale + + gDstWidth = (int)(gSrcWidth * gScale); + gDstHeight = (int)(gSrcHeight * gScale); + gLastDstY = -1; // Reset row tracking + + Serial.printf("[%lu] [PNG] PNG %dx%d -> %dx%d (scale %.2f), bpp: %d\n", millis(), gSrcWidth, gSrcHeight, gDstWidth, + gDstHeight, gScale, png.getBpp()); + + if (png.getBpp() != 8) { + warnUnsupportedFeature("bit depth (" + std::to_string(png.getBpp()) + "bpp)", imagePath); + } + + if (png.hasAlpha()) { + warnUnsupportedFeature("alpha channel", imagePath); + } + + // Allocate cache buffer using SCALED dimensions + bool caching = !config.cachePath.empty(); + if (caching) { + gCacheWidth = gDstWidth; + gCacheHeight = gDstHeight; + gCacheBytesPerRow = (gCacheWidth + 3) / 4; + gCacheOriginX = config.x; + gCacheOriginY = config.y; + size_t bufferSize = gCacheBytesPerRow * gCacheHeight; + gCacheBuffer = (uint8_t*)malloc(bufferSize); + if (gCacheBuffer) { + memset(gCacheBuffer, 0, bufferSize); + Serial.printf("[%lu] [PNG] Allocated cache buffer: %d bytes for %dx%d\n", millis(), bufferSize, gCacheWidth, + gCacheHeight); + } else { + Serial.printf("[%lu] [PNG] Failed to allocate cache buffer, continuing without caching\n", millis()); + caching = false; + } + } + + rc = png.decode(nullptr, 0); + if (rc != PNG_SUCCESS) { + Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc); + png.close(); + file.close(); + gRenderer = nullptr; + gConfig = nullptr; + if (gCacheBuffer) { + free(gCacheBuffer); + gCacheBuffer = nullptr; + } + return false; + } + + png.close(); + file.close(); + Serial.printf("[%lu] [PNG] PNG decoding complete\n", millis()); + + // Write cache file if caching was enabled and buffer was allocated + if (caching && gCacheBuffer) { + FsFile cacheFile; + if (SdMan.openFileForWrite("IMG", config.cachePath, cacheFile)) { + uint16_t w = gCacheWidth; + uint16_t h = gCacheHeight; + cacheFile.write(&w, 2); + cacheFile.write(&h, 2); + cacheFile.write(gCacheBuffer, gCacheBytesPerRow * gCacheHeight); + cacheFile.close(); + Serial.printf("[%lu] [PNG] Cache written: %s (%dx%d, %d bytes)\n", millis(), config.cachePath.c_str(), + gCacheWidth, gCacheHeight, 4 + gCacheBytesPerRow * gCacheHeight); + } else { + Serial.printf("[%lu] [PNG] Failed to open cache file for writing: %s\n", millis(), config.cachePath.c_str()); + } + free(gCacheBuffer); + gCacheBuffer = nullptr; + } + + gRenderer = nullptr; + gConfig = nullptr; + 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 0000000..1083d86 --- /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 d3fb90b..9b4bb83 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]); @@ -130,26 +133,139 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { - // TODO: Start processing image tags + std::string src; std::string alt; if (atts != nullptr) { for (int i = 0; atts[i]; i += 2) { - if (strcmp(atts[i], "alt") == 0) { - // add " " (counts as whitespace) at the end of alt - // so the corresponding text block ends. - // TODO: A zero-width breaking space would be more appropriate (once/if we support it) - alt = "[Image: " + std::string(atts[i + 1]) + "] "; + if (strcmp(atts[i], "src") == 0) { + src = atts[i + 1]; + } else if (strcmp(atts[i], "alt") == 0) { + alt = atts[i + 1]; } } - Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str()); - self->startNewTextBlock(TextBlock::CENTER_ALIGN); - self->italicUntilDepth = min(self->italicUntilDepth, self->depth); - self->depth += 1; - self->characterData(userData, alt.c_str(), alt.length()); - return; - } else { - // Skip for now + 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 + if (self->currentPage && !self->currentPage->elements.empty()) { + 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; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 9042581..0d19a68 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -7,16 +7,19 @@ #include #include "../ParsedText.h" +#include "../blocks/ImageBlock.h" #include "../blocks/TextBlock.h" #include "../css/CssParser.h" #include "../css/CssStyle.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; @@ -41,6 +44,7 @@ class ChapterHtmlSlimParser { uint16_t viewportHeight; bool hyphenationEnabled; const CssParser* cssParser; + int imageCounter = 0; // Style tracking (replaces depth-based approach) struct StyleStackEntry { @@ -70,14 +74,15 @@ 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& progressFn = nullptr, const CssParser* cssParser = nullptr) - : filepath(filepath), + : epub(epub), + filepath(filepath), renderer(renderer), fontId(fontId), lineCompression(lineCompression), diff --git a/platformio.ini b/platformio.ini index 45ccb8b..619f5cd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -33,6 +33,9 @@ build_flags = # mDNS service discovery for companion app network scanning -DMDNS_SERVICE_DISCOVERY=1 -DMDNS_SERVICE_TXT_RECORDS=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 @@ -50,6 +53,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]