A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add png jpeg support (#556)

## Summary
- Add embedded image support to EPUB rendering with JPEG and PNG
decoders
- Implement pixel caching system to cache decoded/dithered images to SD
card for faster re-rendering
- Add 4-level grayscale support for display
## Changes
### New Image Rendering System
- Add `ImageBlock` class to represent an image with its cached path and
display dimensions
- Add `PageImage` class as a new `PageElement` type for images on pages
- Add `ImageToFramebufferDecoder` interface for format-specific image
decoders
- Add `JpegToFramebufferConverter` - JPEG decoder with Bayer dithering
and scaling
- Add `PngToFramebufferConverter` - PNG decoder with Bayer dithering and
scaling
- Add `ImageDecoderFactory` to select appropriate decoder based on file
extension
- Add `getRenderMode()` to GfxRenderer for grayscale render mode queries
### Dithering and Grayscale
- Implement 4x4 Bayer ordered dithering for 4-level grayscale output
- Stateless algorithm works correctly with MCU block decoding
- Handles scaling without artifacts
- Add grayscale render mode support (BW, GRAYSCALE_LSB, GRAYSCALE_MSB)
- Image decoders and cache renderer respect current render mode
- Enables proper 4-level e-ink grayscale when anti-aliasing is enabled
### Pixel Caching
- Cache decoded/dithered images to `.pxc` files on SD card
- Cache format: 2-bit packed pixels (4 pixels per byte) with
width/height header
- On subsequent renders, load directly from cache instead of re-decoding
- Cache renderer supports grayscale render modes for multi-pass
rendering
- Significantly improves page navigation speed for image-heavy EPUBs
### HTML Parser Integration
- Update `ChapterHtmlSlimParser` to process `<img>` tags and extract
images from EPUB
- Resolve relative image paths within EPUB ZIP structure
- Extract images to cache directory before decoding
- Create `PageImage` elements with proper scaling to fit viewport
- Fall back to alt text display if image processing fails
### Build Configuration
- Add `PNG_MAX_BUFFERED_PIXELS=6402` to support up to 800px wide images

### Test Script

- Generate test EPUBs with annotated JPEG and PNG images
- Test cases cover: grayscale (4 levels), centering, scaling, cache
performance

## Test plan
- [x] Open EPUB with JPEG images - verify images display with proper
grayscale
- [x] Open EPUB with PNG images - verify images display correctly and no
crash
- [x] Navigate away from image page and back - verify faster load from
cache
- [x] Verify grayscale tones render correctly (not just black/white
dithering)
- [x] Verify large images are scaled down to fit screen
- [x] Verify images are centered horizontally
- [x] Verify page serialization/deserialization works with images
- [x] Verify images rendered in landscape mode

## Test Results
[png](https://photos.app.goo.gl/5zFUb8xA8db3dPd19)
[jpeg](https://photos.app.goo.gl/SwtwaL2DSQwKybhw7)


![20260128_231123790](https://github.com/user-attachments/assets/78855971-4bb8-441a-b207-0a292b9739f5)

![20260128_231012253](https://github.com/user-attachments/assets/f08fb63f-1b73-41d9-a25e-78232ec0c495)

![20260128_231004209](https://github.com/user-attachments/assets/06c94acc-8a06-4955-978e-6e583399478d)

![20260128_230954997](https://github.com/user-attachments/assets/49bc44d5-0f2c-416b-9199-4d680fb0f4c3)

![20260128_230945717](https://github.com/user-attachments/assets/93446da5-2e07-410c-89c9-6a21d14e5acb)

![20260128_230938313](https://github.com/user-attachments/assets/4c74c72a-3d40-4a25-b0f3-acc703f42c00)

![20260128_230925546](https://github.com/user-attachments/assets/8d8f62ee-c8fc-4f19-a12c-da29083bb766)

![20260128_230918374](https://github.com/user-attachments/assets/f007d5db-41cc-4fa6-bb22-9e767ee7b00d)



---

### AI Usage

Did you use AI tools to help write this code? _**< YES >**_

---------

Co-authored-by: Matthías Páll Gissurarson <mpg@mpg.is>
Co-authored-by: Dave Allie <dave@daveallie.com>

authored by

martin brook
Matthías Páll Gissurarson
Dave Allie
and committed by
GitHub
6c3a615f 46c2109f

+2058 -30
+29 -2
lib/Epub/Epub/Page.cpp
··· 25 25 return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos)); 26 26 } 27 27 28 + void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { 29 + // Images don't use fontId or text rendering 30 + imageBlock->render(renderer, xPos + xOffset, yPos + yOffset); 31 + } 32 + 33 + bool PageImage::serialize(FsFile& file) { 34 + serialization::writePod(file, xPos); 35 + serialization::writePod(file, yPos); 36 + 37 + // serialize ImageBlock 38 + return imageBlock->serialize(file); 39 + } 40 + 41 + std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) { 42 + int16_t xPos; 43 + int16_t yPos; 44 + serialization::readPod(file, xPos); 45 + serialization::readPod(file, yPos); 46 + 47 + auto ib = ImageBlock::deserialize(file); 48 + return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos)); 49 + } 50 + 28 51 void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const { 29 52 for (auto& element : elements) { 30 53 element->render(renderer, fontId, xOffset, yOffset); ··· 36 59 serialization::writePod(file, count); 37 60 38 61 for (const auto& el : elements) { 39 - // Only PageLine exists currently 40 - serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine)); 62 + // Use getTag() method to determine type 63 + serialization::writePod(file, static_cast<uint8_t>(el->getTag())); 64 + 41 65 if (!el->serialize(file)) { 42 66 return false; 43 67 } ··· 59 83 if (tag == TAG_PageLine) { 60 84 auto pl = PageLine::deserialize(file); 61 85 page->elements.push_back(std::move(pl)); 86 + } else if (tag == TAG_PageImage) { 87 + auto pi = PageImage::deserialize(file); 88 + page->elements.push_back(std::move(pi)); 62 89 } else { 63 90 LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag); 64 91 return nullptr;
+24
lib/Epub/Epub/Page.h
··· 1 1 #pragma once 2 2 #include <HalStorage.h> 3 3 4 + #include <algorithm> 4 5 #include <utility> 5 6 #include <vector> 6 7 8 + #include "blocks/ImageBlock.h" 7 9 #include "blocks/TextBlock.h" 8 10 9 11 enum PageElementTag : uint8_t { 10 12 TAG_PageLine = 1, 13 + TAG_PageImage = 2, // New tag 11 14 }; 12 15 13 16 // represents something that has been added to a page ··· 19 22 virtual ~PageElement() = default; 20 23 virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0; 21 24 virtual bool serialize(FsFile& file) = 0; 25 + virtual PageElementTag getTag() const = 0; // Add type identification 22 26 }; 23 27 24 28 // a line from a block element ··· 30 34 : PageElement(xPos, yPos), block(std::move(block)) {} 31 35 void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; 32 36 bool serialize(FsFile& file) override; 37 + PageElementTag getTag() const override { return TAG_PageLine; } 33 38 static std::unique_ptr<PageLine> deserialize(FsFile& file); 34 39 }; 35 40 41 + // New PageImage class 42 + class PageImage final : public PageElement { 43 + std::shared_ptr<ImageBlock> imageBlock; 44 + 45 + public: 46 + PageImage(std::shared_ptr<ImageBlock> block, const int16_t xPos, const int16_t yPos) 47 + : PageElement(xPos, yPos), imageBlock(std::move(block)) {} 48 + void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; 49 + bool serialize(FsFile& file) override; 50 + PageElementTag getTag() const override { return TAG_PageImage; } 51 + static std::unique_ptr<PageImage> deserialize(FsFile& file); 52 + }; 53 + 36 54 class Page { 37 55 public: 38 56 // the list of block index and line numbers on this page ··· 40 58 void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; 41 59 bool serialize(FsFile& file) const; 42 60 static std::unique_ptr<Page> deserialize(FsFile& file); 61 + 62 + // Check if page contains any images (used to force full refresh) 63 + bool hasImages() const { 64 + return std::any_of(elements.begin(), elements.end(), 65 + [](const std::shared_ptr<PageElement>& el) { return el->getTag() == TAG_PageImage; }); 66 + } 43 67 };
+9 -3
lib/Epub/Epub/Section.cpp
··· 9 9 #include "parsers/ChapterHtmlSlimParser.h" 10 10 11 11 namespace { 12 - constexpr uint8_t SECTION_FILE_VERSION = 12; 12 + constexpr uint8_t SECTION_FILE_VERSION = 13; 13 13 constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + 14 14 sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + 15 15 sizeof(uint32_t); ··· 181 181 viewportHeight, hyphenationEnabled, embeddedStyle); 182 182 std::vector<uint32_t> lut = {}; 183 183 184 + // Derive the content base directory and image cache path prefix for the parser 185 + size_t lastSlash = localPath.find_last_of('/'); 186 + std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : ""; 187 + std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_"; 188 + 184 189 CssParser* cssParser = nullptr; 185 190 if (embeddedStyle) { 186 191 cssParser = epub->getCssParser(); ··· 190 195 } 191 196 } 192 197 } 198 + 193 199 ChapterHtmlSlimParser visitor( 194 - tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, 200 + epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, 195 201 viewportHeight, hyphenationEnabled, 196 202 [this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, 197 - embeddedStyle, popupFn, cssParser); 203 + embeddedStyle, contentBase, imageBasePath, popupFn, cssParser); 198 204 Hyphenator::setPreferredLanguage(epub->getLanguage()); 199 205 success = visitor.parseAndBuildPages(); 200 206
+1 -1
lib/Epub/Epub/blocks/Block.h
··· 8 8 class Block { 9 9 public: 10 10 virtual ~Block() = default; 11 - virtual void layout(GfxRenderer& renderer) = 0; 11 + 12 12 virtual BlockType getType() = 0; 13 13 virtual bool isEmpty() = 0; 14 14 virtual void finish() {}
+175
lib/Epub/Epub/blocks/ImageBlock.cpp
··· 1 + #include "ImageBlock.h" 2 + 3 + #include <FsHelpers.h> 4 + #include <GfxRenderer.h> 5 + #include <HardwareSerial.h> 6 + #include <SDCardManager.h> 7 + #include <Serialization.h> 8 + 9 + #include "../converters/DitherUtils.h" 10 + #include "../converters/ImageDecoderFactory.h" 11 + 12 + // Cache file format: 13 + // - uint16_t width 14 + // - uint16_t height 15 + // - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order 16 + 17 + ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height) 18 + : imagePath(imagePath), width(width), height(height) {} 19 + 20 + bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); } 21 + 22 + namespace { 23 + 24 + std::string getCachePath(const std::string& imagePath) { 25 + // Replace extension with .pxc (pixel cache) 26 + size_t dotPos = imagePath.rfind('.'); 27 + if (dotPos != std::string::npos) { 28 + return imagePath.substr(0, dotPos) + ".pxc"; 29 + } 30 + return imagePath + ".pxc"; 31 + } 32 + 33 + bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth, 34 + int expectedHeight) { 35 + FsFile cacheFile; 36 + if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) { 37 + return false; 38 + } 39 + 40 + uint16_t cachedWidth, cachedHeight; 41 + if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) { 42 + cacheFile.close(); 43 + return false; 44 + } 45 + 46 + // Verify dimensions are close (allow 1 pixel tolerance for rounding differences) 47 + int widthDiff = abs(cachedWidth - expectedWidth); 48 + int heightDiff = abs(cachedHeight - expectedHeight); 49 + if (widthDiff > 1 || heightDiff > 1) { 50 + Serial.printf("[%lu] [IMG] Cache dimension mismatch: %dx%d vs %dx%d\n", millis(), cachedWidth, cachedHeight, 51 + expectedWidth, expectedHeight); 52 + cacheFile.close(); 53 + return false; 54 + } 55 + 56 + // Use cached dimensions for rendering (they're the actual decoded size) 57 + expectedWidth = cachedWidth; 58 + expectedHeight = cachedHeight; 59 + 60 + Serial.printf("[%lu] [IMG] Loading from cache: %s (%dx%d)\n", millis(), cachePath.c_str(), cachedWidth, cachedHeight); 61 + 62 + // Read and render row by row to minimize memory usage 63 + const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte 64 + uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow); 65 + if (!rowBuffer) { 66 + Serial.printf("[%lu] [IMG] Failed to allocate row buffer\n", millis()); 67 + cacheFile.close(); 68 + return false; 69 + } 70 + 71 + for (int row = 0; row < cachedHeight; row++) { 72 + if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) { 73 + Serial.printf("[%lu] [IMG] Cache read error at row %d\n", millis(), row); 74 + free(rowBuffer); 75 + cacheFile.close(); 76 + return false; 77 + } 78 + 79 + int destY = y + row; 80 + for (int col = 0; col < cachedWidth; col++) { 81 + int byteIdx = col / 4; 82 + int bitShift = 6 - (col % 4) * 2; // MSB first within byte 83 + uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03; 84 + 85 + drawPixelWithRenderMode(renderer, x + col, destY, pixelValue); 86 + } 87 + } 88 + 89 + free(rowBuffer); 90 + cacheFile.close(); 91 + Serial.printf("[%lu] [IMG] Cache render complete\n", millis()); 92 + return true; 93 + } 94 + 95 + } // namespace 96 + 97 + void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) { 98 + Serial.printf("[%lu] [IMG] Rendering image at %d,%d: %s (%dx%d)\n", millis(), x, y, imagePath.c_str(), width, height); 99 + 100 + const int screenWidth = renderer.getScreenWidth(); 101 + const int screenHeight = renderer.getScreenHeight(); 102 + 103 + // Bounds check render position using logical screen dimensions 104 + if (x < 0 || y < 0 || x + width > screenWidth || y + height > screenHeight) { 105 + Serial.printf("[%lu] [IMG] Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)\n", millis(), x, y, width, 106 + height, screenWidth, screenHeight); 107 + return; 108 + } 109 + 110 + // Try to render from cache first 111 + std::string cachePath = getCachePath(imagePath); 112 + if (renderFromCache(renderer, cachePath, x, y, width, height)) { 113 + return; // Successfully rendered from cache 114 + } 115 + 116 + // No cache - need to decode the image 117 + // Check if image file exists 118 + FsFile file; 119 + if (!Storage.openFileForRead("IMG", imagePath, file)) { 120 + Serial.printf("[%lu] [IMG] Image file not found: %s\n", millis(), imagePath.c_str()); 121 + return; 122 + } 123 + size_t fileSize = file.size(); 124 + file.close(); 125 + 126 + if (fileSize == 0) { 127 + Serial.printf("[%lu] [IMG] Image file is empty: %s\n", millis(), imagePath.c_str()); 128 + return; 129 + } 130 + 131 + Serial.printf("[%lu] [IMG] Decoding and caching: %s\n", millis(), imagePath.c_str()); 132 + 133 + RenderConfig config; 134 + config.x = x; 135 + config.y = y; 136 + config.maxWidth = width; 137 + config.maxHeight = height; 138 + config.useGrayscale = true; 139 + config.useDithering = true; 140 + config.performanceMode = false; 141 + config.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches 142 + config.cachePath = cachePath; // Enable caching during decode 143 + 144 + ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath); 145 + if (!decoder) { 146 + Serial.printf("[%lu] [IMG] No decoder found for image: %s\n", millis(), imagePath.c_str()); 147 + return; 148 + } 149 + 150 + Serial.printf("[%lu] [IMG] Using %s decoder\n", millis(), decoder->getFormatName()); 151 + 152 + bool success = decoder->decodeToFramebuffer(imagePath, renderer, config); 153 + if (!success) { 154 + Serial.printf("[%lu] [IMG] Failed to decode image: %s\n", millis(), imagePath.c_str()); 155 + return; 156 + } 157 + 158 + Serial.printf("[%lu] [IMG] Decode successful\n", millis()); 159 + } 160 + 161 + bool ImageBlock::serialize(FsFile& file) { 162 + serialization::writeString(file, imagePath); 163 + serialization::writePod(file, width); 164 + serialization::writePod(file, height); 165 + return true; 166 + } 167 + 168 + std::unique_ptr<ImageBlock> ImageBlock::deserialize(FsFile& file) { 169 + std::string path; 170 + serialization::readString(file, path); 171 + int16_t w, h; 172 + serialization::readPod(file, w); 173 + serialization::readPod(file, h); 174 + return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h)); 175 + }
+31
lib/Epub/Epub/blocks/ImageBlock.h
··· 1 + #pragma once 2 + #include <SdFat.h> 3 + 4 + #include <memory> 5 + #include <string> 6 + 7 + #include "Block.h" 8 + 9 + class ImageBlock final : public Block { 10 + public: 11 + ImageBlock(const std::string& imagePath, int16_t width, int16_t height); 12 + ~ImageBlock() override = default; 13 + 14 + const std::string& getImagePath() const { return imagePath; } 15 + int16_t getWidth() const { return width; } 16 + int16_t getHeight() const { return height; } 17 + 18 + bool imageExists() const; 19 + 20 + BlockType getType() override { return IMAGE_BLOCK; } 21 + bool isEmpty() override { return false; } 22 + 23 + void render(GfxRenderer& renderer, const int x, const int y); 24 + bool serialize(FsFile& file); 25 + static std::unique_ptr<ImageBlock> deserialize(FsFile& file); 26 + 27 + private: 28 + std::string imagePath; 29 + int16_t width; 30 + int16_t height; 31 + };
-1
lib/Epub/Epub/blocks/TextBlock.h
··· 28 28 void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } 29 29 const BlockStyle& getBlockStyle() const { return blockStyle; } 30 30 bool isEmpty() override { return words.empty(); } 31 - void layout(GfxRenderer& renderer) override {}; 32 31 // given a renderer works out where to break the words into lines 33 32 void render(const GfxRenderer& renderer, int fontId, int x, int y) const; 34 33 BlockType getType() override { return TEXT_BLOCK; }
+40
lib/Epub/Epub/converters/DitherUtils.h
··· 1 + #pragma once 2 + 3 + #include <GfxRenderer.h> 4 + #include <stdint.h> 5 + 6 + // 4x4 Bayer matrix for ordered dithering 7 + inline const uint8_t bayer4x4[4][4] = { 8 + {0, 8, 2, 10}, 9 + {12, 4, 14, 6}, 10 + {3, 11, 1, 9}, 11 + {15, 7, 13, 5}, 12 + }; 13 + 14 + // Apply Bayer dithering and quantize to 4 levels (0-3) 15 + // Stateless - works correctly with any pixel processing order 16 + inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) { 17 + int bayer = bayer4x4[y & 3][x & 3]; 18 + int dither = (bayer - 8) * 5; // Scale to +/-40 (half of quantization step 85) 19 + 20 + int adjusted = gray + dither; 21 + if (adjusted < 0) adjusted = 0; 22 + if (adjusted > 255) adjusted = 255; 23 + 24 + if (adjusted < 64) return 0; 25 + if (adjusted < 128) return 1; 26 + if (adjusted < 192) return 2; 27 + return 3; 28 + } 29 + 30 + // Draw a pixel respecting the current render mode for grayscale support 31 + inline void drawPixelWithRenderMode(GfxRenderer& renderer, int x, int y, uint8_t pixelValue) { 32 + GfxRenderer::RenderMode renderMode = renderer.getRenderMode(); 33 + if (renderMode == GfxRenderer::BW && pixelValue < 3) { 34 + renderer.drawPixel(x, y, true); 35 + } else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (pixelValue == 1 || pixelValue == 2)) { 36 + renderer.drawPixel(x, y, false); 37 + } else if (renderMode == GfxRenderer::GRAYSCALE_LSB && pixelValue == 1) { 38 + renderer.drawPixel(x, y, false); 39 + } 40 + }
+42
lib/Epub/Epub/converters/ImageDecoderFactory.cpp
··· 1 + #include "ImageDecoderFactory.h" 2 + 3 + #include <HardwareSerial.h> 4 + 5 + #include <memory> 6 + #include <string> 7 + 8 + #include "JpegToFramebufferConverter.h" 9 + #include "PngToFramebufferConverter.h" 10 + 11 + std::unique_ptr<JpegToFramebufferConverter> ImageDecoderFactory::jpegDecoder = nullptr; 12 + std::unique_ptr<PngToFramebufferConverter> ImageDecoderFactory::pngDecoder = nullptr; 13 + 14 + ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& imagePath) { 15 + std::string ext = imagePath; 16 + size_t dotPos = ext.rfind('.'); 17 + if (dotPos != std::string::npos) { 18 + ext = ext.substr(dotPos); 19 + for (auto& c : ext) { 20 + c = tolower(c); 21 + } 22 + } else { 23 + ext = ""; 24 + } 25 + 26 + if (JpegToFramebufferConverter::supportsFormat(ext)) { 27 + if (!jpegDecoder) { 28 + jpegDecoder.reset(new JpegToFramebufferConverter()); 29 + } 30 + return jpegDecoder.get(); 31 + } else if (PngToFramebufferConverter::supportsFormat(ext)) { 32 + if (!pngDecoder) { 33 + pngDecoder.reset(new PngToFramebufferConverter()); 34 + } 35 + return pngDecoder.get(); 36 + } 37 + 38 + Serial.printf("[%lu] [DEC] No decoder found for image: %s\n", millis(), imagePath.c_str()); 39 + return nullptr; 40 + } 41 + 42 + bool ImageDecoderFactory::isFormatSupported(const std::string& imagePath) { return getDecoder(imagePath) != nullptr; }
+20
lib/Epub/Epub/converters/ImageDecoderFactory.h
··· 1 + #pragma once 2 + #include <cstdint> 3 + #include <memory> 4 + #include <string> 5 + 6 + #include "ImageToFramebufferDecoder.h" 7 + 8 + class JpegToFramebufferConverter; 9 + class PngToFramebufferConverter; 10 + 11 + class ImageDecoderFactory { 12 + public: 13 + // Returns non-owning pointer - factory owns the decoder lifetime 14 + static ImageToFramebufferDecoder* getDecoder(const std::string& imagePath); 15 + static bool isFormatSupported(const std::string& imagePath); 16 + 17 + private: 18 + static std::unique_ptr<JpegToFramebufferConverter> jpegDecoder; 19 + static std::unique_ptr<PngToFramebufferConverter> pngDecoder; 20 + };
+18
lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp
··· 1 + #include "ImageToFramebufferDecoder.h" 2 + 3 + #include <Arduino.h> 4 + #include <HardwareSerial.h> 5 + 6 + bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) { 7 + if (width * height > MAX_SOURCE_PIXELS) { 8 + Serial.printf("[%lu] [IMG] Image too large (%dx%d = %d pixels %s), max supported: %d pixels\n", millis(), width, 9 + height, width * height, format.c_str(), MAX_SOURCE_PIXELS); 10 + return false; 11 + } 12 + return true; 13 + } 14 + 15 + void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) { 16 + Serial.printf("[%lu] [IMG] Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.\n", 17 + millis(), feature.c_str(), imagePath.c_str()); 18 + }
+40
lib/Epub/Epub/converters/ImageToFramebufferDecoder.h
··· 1 + #pragma once 2 + #include <SdFat.h> 3 + 4 + #include <memory> 5 + #include <string> 6 + 7 + class GfxRenderer; 8 + 9 + struct ImageDimensions { 10 + int16_t width; 11 + int16_t height; 12 + }; 13 + 14 + struct RenderConfig { 15 + int x, y; 16 + int maxWidth, maxHeight; 17 + bool useGrayscale = true; 18 + bool useDithering = true; 19 + bool performanceMode = false; 20 + bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation) 21 + std::string cachePath; // If non-empty, decoder will write pixel cache to this path 22 + }; 23 + 24 + class ImageToFramebufferDecoder { 25 + public: 26 + virtual ~ImageToFramebufferDecoder() = default; 27 + 28 + virtual bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) = 0; 29 + 30 + virtual bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const = 0; 31 + 32 + virtual const char* getFormatName() const = 0; 33 + 34 + protected: 35 + // Size validation helpers 36 + static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536 37 + 38 + bool validateImageDimensions(int width, int height, const std::string& format); 39 + void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath); 40 + };
+298
lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp
··· 1 + #include "JpegToFramebufferConverter.h" 2 + 3 + #include <GfxRenderer.h> 4 + #include <HardwareSerial.h> 5 + #include <SDCardManager.h> 6 + #include <SdFat.h> 7 + #include <picojpeg.h> 8 + 9 + #include <cstdio> 10 + #include <cstring> 11 + 12 + #include "DitherUtils.h" 13 + #include "PixelCache.h" 14 + 15 + struct JpegContext { 16 + FsFile& file; 17 + uint8_t buffer[512]; 18 + size_t bufferPos; 19 + size_t bufferFilled; 20 + JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {} 21 + }; 22 + 23 + bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) { 24 + FsFile file; 25 + if (!Storage.openFileForRead("JPG", imagePath, file)) { 26 + Serial.printf("[%lu] [JPG] Failed to open file for dimensions: %s\n", millis(), imagePath.c_str()); 27 + return false; 28 + } 29 + 30 + JpegContext context(file); 31 + pjpeg_image_info_t imageInfo; 32 + 33 + int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0); 34 + file.close(); 35 + 36 + if (status != 0) { 37 + Serial.printf("[%lu] [JPG] Failed to init JPEG for dimensions: %d\n", millis(), status); 38 + return false; 39 + } 40 + 41 + out.width = imageInfo.m_width; 42 + out.height = imageInfo.m_height; 43 + Serial.printf("[%lu] [JPG] Image dimensions: %dx%d\n", millis(), out.width, out.height); 44 + return true; 45 + } 46 + 47 + bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, 48 + const RenderConfig& config) { 49 + Serial.printf("[%lu] [JPG] Decoding JPEG: %s\n", millis(), imagePath.c_str()); 50 + 51 + FsFile file; 52 + if (!Storage.openFileForRead("JPG", imagePath, file)) { 53 + Serial.printf("[%lu] [JPG] Failed to open file: %s\n", millis(), imagePath.c_str()); 54 + return false; 55 + } 56 + 57 + JpegContext context(file); 58 + pjpeg_image_info_t imageInfo; 59 + 60 + int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0); 61 + if (status != 0) { 62 + Serial.printf("[%lu] [JPG] picojpeg init failed: %d\n", millis(), status); 63 + file.close(); 64 + return false; 65 + } 66 + 67 + if (!validateImageDimensions(imageInfo.m_width, imageInfo.m_height, "JPEG")) { 68 + file.close(); 69 + return false; 70 + } 71 + 72 + // Calculate output dimensions 73 + int destWidth, destHeight; 74 + float scale; 75 + 76 + if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) { 77 + // Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes) 78 + destWidth = config.maxWidth; 79 + destHeight = config.maxHeight; 80 + scale = (float)destWidth / imageInfo.m_width; 81 + } else { 82 + // Calculate scale factor to fit within maxWidth/maxHeight 83 + float scaleX = (config.maxWidth > 0 && imageInfo.m_width > config.maxWidth) 84 + ? (float)config.maxWidth / imageInfo.m_width 85 + : 1.0f; 86 + float scaleY = (config.maxHeight > 0 && imageInfo.m_height > config.maxHeight) 87 + ? (float)config.maxHeight / imageInfo.m_height 88 + : 1.0f; 89 + scale = (scaleX < scaleY) ? scaleX : scaleY; 90 + if (scale > 1.0f) scale = 1.0f; 91 + 92 + destWidth = (int)(imageInfo.m_width * scale); 93 + destHeight = (int)(imageInfo.m_height * scale); 94 + } 95 + 96 + Serial.printf("[%lu] [JPG] JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d\n", millis(), 97 + imageInfo.m_width, imageInfo.m_height, destWidth, destHeight, scale, imageInfo.m_scanType, 98 + imageInfo.m_MCUWidth, imageInfo.m_MCUHeight); 99 + 100 + if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) { 101 + Serial.printf("[%lu] [JPG] Null buffer pointers in imageInfo\n", millis()); 102 + file.close(); 103 + return false; 104 + } 105 + 106 + const int screenWidth = renderer.getScreenWidth(); 107 + const int screenHeight = renderer.getScreenHeight(); 108 + 109 + // Allocate pixel cache if cachePath is provided 110 + PixelCache cache; 111 + bool caching = !config.cachePath.empty(); 112 + if (caching) { 113 + if (!cache.allocate(destWidth, destHeight, config.x, config.y)) { 114 + Serial.printf("[%lu] [JPG] Failed to allocate cache buffer, continuing without caching\n", millis()); 115 + caching = false; 116 + } 117 + } 118 + 119 + int mcuX = 0; 120 + int mcuY = 0; 121 + 122 + while (mcuY < imageInfo.m_MCUSPerCol) { 123 + status = pjpeg_decode_mcu(); 124 + if (status == PJPG_NO_MORE_BLOCKS) { 125 + break; 126 + } 127 + if (status != 0) { 128 + Serial.printf("[%lu] [JPG] MCU decode failed: %d\n", millis(), status); 129 + file.close(); 130 + return false; 131 + } 132 + 133 + // Source position in image coordinates 134 + int srcStartX = mcuX * imageInfo.m_MCUWidth; 135 + int srcStartY = mcuY * imageInfo.m_MCUHeight; 136 + 137 + switch (imageInfo.m_scanType) { 138 + case PJPG_GRAYSCALE: 139 + for (int row = 0; row < 8; row++) { 140 + int srcY = srcStartY + row; 141 + int destY = config.y + (int)(srcY * scale); 142 + if (destY >= screenHeight || destY >= config.y + destHeight) continue; 143 + for (int col = 0; col < 8; col++) { 144 + int srcX = srcStartX + col; 145 + int destX = config.x + (int)(srcX * scale); 146 + if (destX >= screenWidth || destX >= config.x + destWidth) continue; 147 + uint8_t gray = imageInfo.m_pMCUBufR[row * 8 + col]; 148 + uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85; 149 + if (dithered > 3) dithered = 3; 150 + drawPixelWithRenderMode(renderer, destX, destY, dithered); 151 + if (caching) cache.setPixel(destX, destY, dithered); 152 + } 153 + } 154 + break; 155 + 156 + case PJPG_YH1V1: 157 + for (int row = 0; row < 8; row++) { 158 + int srcY = srcStartY + row; 159 + int destY = config.y + (int)(srcY * scale); 160 + if (destY >= screenHeight || destY >= config.y + destHeight) continue; 161 + for (int col = 0; col < 8; col++) { 162 + int srcX = srcStartX + col; 163 + int destX = config.x + (int)(srcX * scale); 164 + if (destX >= screenWidth || destX >= config.x + destWidth) continue; 165 + uint8_t r = imageInfo.m_pMCUBufR[row * 8 + col]; 166 + uint8_t g = imageInfo.m_pMCUBufG[row * 8 + col]; 167 + uint8_t b = imageInfo.m_pMCUBufB[row * 8 + col]; 168 + uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8); 169 + uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85; 170 + if (dithered > 3) dithered = 3; 171 + drawPixelWithRenderMode(renderer, destX, destY, dithered); 172 + if (caching) cache.setPixel(destX, destY, dithered); 173 + } 174 + } 175 + break; 176 + 177 + case PJPG_YH2V1: 178 + for (int row = 0; row < 8; row++) { 179 + int srcY = srcStartY + row; 180 + int destY = config.y + (int)(srcY * scale); 181 + if (destY >= screenHeight || destY >= config.y + destHeight) continue; 182 + for (int col = 0; col < 16; col++) { 183 + int srcX = srcStartX + col; 184 + int destX = config.x + (int)(srcX * scale); 185 + if (destX >= screenWidth || destX >= config.x + destWidth) continue; 186 + int blockIndex = (col < 8) ? 0 : 1; 187 + int pixelIndex = row * 8 + (col % 8); 188 + uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 64 + pixelIndex]; 189 + uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 64 + pixelIndex]; 190 + uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 64 + pixelIndex]; 191 + uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8); 192 + uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85; 193 + if (dithered > 3) dithered = 3; 194 + drawPixelWithRenderMode(renderer, destX, destY, dithered); 195 + if (caching) cache.setPixel(destX, destY, dithered); 196 + } 197 + } 198 + break; 199 + 200 + case PJPG_YH1V2: 201 + for (int row = 0; row < 16; row++) { 202 + int srcY = srcStartY + row; 203 + int destY = config.y + (int)(srcY * scale); 204 + if (destY >= screenHeight || destY >= config.y + destHeight) continue; 205 + for (int col = 0; col < 8; col++) { 206 + int srcX = srcStartX + col; 207 + int destX = config.x + (int)(srcX * scale); 208 + if (destX >= screenWidth || destX >= config.x + destWidth) continue; 209 + int blockIndex = (row < 8) ? 0 : 1; 210 + int pixelIndex = (row % 8) * 8 + col; 211 + uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 128 + pixelIndex]; 212 + uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 128 + pixelIndex]; 213 + uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 128 + pixelIndex]; 214 + uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8); 215 + uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85; 216 + if (dithered > 3) dithered = 3; 217 + drawPixelWithRenderMode(renderer, destX, destY, dithered); 218 + if (caching) cache.setPixel(destX, destY, dithered); 219 + } 220 + } 221 + break; 222 + 223 + case PJPG_YH2V2: 224 + for (int row = 0; row < 16; row++) { 225 + int srcY = srcStartY + row; 226 + int destY = config.y + (int)(srcY * scale); 227 + if (destY >= screenHeight || destY >= config.y + destHeight) continue; 228 + for (int col = 0; col < 16; col++) { 229 + int srcX = srcStartX + col; 230 + int destX = config.x + (int)(srcX * scale); 231 + if (destX >= screenWidth || destX >= config.x + destWidth) continue; 232 + int blockX = (col < 8) ? 0 : 1; 233 + int blockY = (row < 8) ? 0 : 1; 234 + int blockIndex = blockY * 2 + blockX; 235 + int pixelIndex = (row % 8) * 8 + (col % 8); 236 + int blockOffset = blockIndex * 64; 237 + uint8_t r = imageInfo.m_pMCUBufR[blockOffset + pixelIndex]; 238 + uint8_t g = imageInfo.m_pMCUBufG[blockOffset + pixelIndex]; 239 + uint8_t b = imageInfo.m_pMCUBufB[blockOffset + pixelIndex]; 240 + uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8); 241 + uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85; 242 + if (dithered > 3) dithered = 3; 243 + drawPixelWithRenderMode(renderer, destX, destY, dithered); 244 + if (caching) cache.setPixel(destX, destY, dithered); 245 + } 246 + } 247 + break; 248 + } 249 + 250 + mcuX++; 251 + if (mcuX >= imageInfo.m_MCUSPerRow) { 252 + mcuX = 0; 253 + mcuY++; 254 + } 255 + } 256 + 257 + Serial.printf("[%lu] [JPG] Decoding complete\n", millis()); 258 + file.close(); 259 + 260 + // Write cache file if caching was enabled 261 + if (caching) { 262 + cache.writeToFile(config.cachePath); 263 + } 264 + 265 + return true; 266 + } 267 + 268 + unsigned char JpegToFramebufferConverter::jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, 269 + unsigned char* pBytes_actually_read, void* pCallback_data) { 270 + JpegContext* context = reinterpret_cast<JpegContext*>(pCallback_data); 271 + 272 + if (context->bufferPos >= context->bufferFilled) { 273 + int readCount = context->file.read(context->buffer, sizeof(context->buffer)); 274 + if (readCount <= 0) { 275 + *pBytes_actually_read = 0; 276 + return 0; 277 + } 278 + context->bufferFilled = readCount; 279 + context->bufferPos = 0; 280 + } 281 + 282 + unsigned int bytesAvailable = context->bufferFilled - context->bufferPos; 283 + unsigned int bytesToCopy = (bytesAvailable < buf_size) ? bytesAvailable : buf_size; 284 + 285 + memcpy(pBuf, &context->buffer[context->bufferPos], bytesToCopy); 286 + context->bufferPos += bytesToCopy; 287 + *pBytes_actually_read = bytesToCopy; 288 + 289 + return 0; 290 + } 291 + 292 + bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) { 293 + std::string ext = extension; 294 + for (auto& c : ext) { 295 + c = tolower(c); 296 + } 297 + return (ext == ".jpg" || ext == ".jpeg"); 298 + }
+24
lib/Epub/Epub/converters/JpegToFramebufferConverter.h
··· 1 + #pragma once 2 + #include <stdint.h> 3 + 4 + #include <string> 5 + 6 + #include "ImageToFramebufferDecoder.h" 7 + 8 + class JpegToFramebufferConverter final : public ImageToFramebufferDecoder { 9 + public: 10 + static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out); 11 + 12 + bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override; 13 + 14 + bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override { 15 + return getDimensionsStatic(imagePath, dims); 16 + } 17 + 18 + static bool supportsFormat(const std::string& extension); 19 + const char* getFormatName() const override { return "JPEG"; } 20 + 21 + private: 22 + static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, 23 + unsigned char* pBytes_actually_read, void* pCallback_data); 24 + };
+85
lib/Epub/Epub/converters/PixelCache.h
··· 1 + #pragma once 2 + 3 + #include <HardwareSerial.h> 4 + #include <SDCardManager.h> 5 + #include <SdFat.h> 6 + #include <stdint.h> 7 + 8 + #include <cstring> 9 + #include <string> 10 + 11 + // Cache buffer for storing 2-bit pixels (4 levels) during decode. 12 + // Packs 4 pixels per byte, MSB first. 13 + struct PixelCache { 14 + uint8_t* buffer; 15 + int width; 16 + int height; 17 + int bytesPerRow; 18 + int originX; // config.x - to convert screen coords to cache coords 19 + int originY; // config.y 20 + 21 + PixelCache() : buffer(nullptr), width(0), height(0), bytesPerRow(0), originX(0), originY(0) {} 22 + PixelCache(const PixelCache&) = delete; 23 + PixelCache& operator=(const PixelCache&) = delete; 24 + 25 + static constexpr size_t MAX_CACHE_BYTES = 256 * 1024; // 256KB limit for embedded targets 26 + 27 + bool allocate(int w, int h, int ox, int oy) { 28 + width = w; 29 + height = h; 30 + originX = ox; 31 + originY = oy; 32 + bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte 33 + size_t bufferSize = (size_t)bytesPerRow * h; 34 + if (bufferSize > MAX_CACHE_BYTES) { 35 + Serial.printf("[%lu] [IMG] Cache buffer too large: %d bytes for %dx%d (limit %d)\n", millis(), bufferSize, w, h, 36 + MAX_CACHE_BYTES); 37 + return false; 38 + } 39 + buffer = (uint8_t*)malloc(bufferSize); 40 + if (buffer) { 41 + memset(buffer, 0, bufferSize); 42 + Serial.printf("[%lu] [IMG] Allocated cache buffer: %d bytes for %dx%d\n", millis(), bufferSize, w, h); 43 + } 44 + return buffer != nullptr; 45 + } 46 + 47 + void setPixel(int screenX, int screenY, uint8_t value) { 48 + if (!buffer) return; 49 + int localX = screenX - originX; 50 + int localY = screenY - originY; 51 + if (localX < 0 || localX >= width || localY < 0 || localY >= height) return; 52 + 53 + int byteIdx = localY * bytesPerRow + localX / 4; 54 + int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7 55 + buffer[byteIdx] = (buffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift); 56 + } 57 + 58 + bool writeToFile(const std::string& cachePath) { 59 + if (!buffer) return false; 60 + 61 + FsFile cacheFile; 62 + if (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) { 63 + Serial.printf("[%lu] [IMG] Failed to open cache file for writing: %s\n", millis(), cachePath.c_str()); 64 + return false; 65 + } 66 + 67 + uint16_t w = width; 68 + uint16_t h = height; 69 + cacheFile.write(&w, 2); 70 + cacheFile.write(&h, 2); 71 + cacheFile.write(buffer, bytesPerRow * height); 72 + cacheFile.close(); 73 + 74 + Serial.printf("[%lu] [IMG] Cache written: %s (%dx%d, %d bytes)\n", millis(), cachePath.c_str(), width, height, 75 + 4 + bytesPerRow * height); 76 + return true; 77 + } 78 + 79 + ~PixelCache() { 80 + if (buffer) { 81 + free(buffer); 82 + buffer = nullptr; 83 + } 84 + } 85 + };
+364
lib/Epub/Epub/converters/PngToFramebufferConverter.cpp
··· 1 + #include "PngToFramebufferConverter.h" 2 + 3 + #include <GfxRenderer.h> 4 + #include <HardwareSerial.h> 5 + #include <PNGdec.h> 6 + #include <SDCardManager.h> 7 + #include <SdFat.h> 8 + 9 + #include <cstdlib> 10 + #include <new> 11 + 12 + #include "DitherUtils.h" 13 + #include "PixelCache.h" 14 + 15 + namespace { 16 + 17 + // Context struct passed through PNGdec callbacks to avoid global mutable state. 18 + // The draw callback receives this via pDraw->pUser (set by png.decode()). 19 + // The file I/O callbacks receive the FsFile* via pFile->fHandle (set by pngOpen()). 20 + struct PngContext { 21 + GfxRenderer* renderer; 22 + const RenderConfig* config; 23 + int screenWidth; 24 + int screenHeight; 25 + 26 + // Scaling state 27 + float scale; 28 + int srcWidth; 29 + int srcHeight; 30 + int dstWidth; 31 + int dstHeight; 32 + int lastDstY; // Track last rendered destination Y to avoid duplicates 33 + 34 + PixelCache cache; 35 + bool caching; 36 + 37 + uint8_t* grayLineBuffer; 38 + 39 + PngContext() 40 + : renderer(nullptr), 41 + config(nullptr), 42 + screenWidth(0), 43 + screenHeight(0), 44 + scale(1.0f), 45 + srcWidth(0), 46 + srcHeight(0), 47 + dstWidth(0), 48 + dstHeight(0), 49 + lastDstY(-1), 50 + caching(false), 51 + grayLineBuffer(nullptr) {} 52 + }; 53 + 54 + // File I/O callbacks use pFile->fHandle to access the FsFile*, 55 + // avoiding the need for global file state. 56 + void* pngOpenWithHandle(const char* filename, int32_t* size) { 57 + FsFile* f = new FsFile(); 58 + if (!Storage.openFileForRead("PNG", std::string(filename), *f)) { 59 + delete f; 60 + return nullptr; 61 + } 62 + *size = f->size(); 63 + return f; 64 + } 65 + 66 + void pngCloseWithHandle(void* handle) { 67 + FsFile* f = reinterpret_cast<FsFile*>(handle); 68 + if (f) { 69 + f->close(); 70 + delete f; 71 + } 72 + } 73 + 74 + int32_t pngReadWithHandle(PNGFILE* pFile, uint8_t* pBuf, int32_t len) { 75 + FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle); 76 + if (!f) return 0; 77 + return f->read(pBuf, len); 78 + } 79 + 80 + int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) { 81 + FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle); 82 + if (!f) return -1; 83 + return f->seek(pos); 84 + } 85 + 86 + // The PNG decoder (PNGdec) is ~42 KB due to internal zlib decompression buffers. 87 + // We heap-allocate it on demand rather than using a static instance, so this memory 88 + // is only consumed while actually decoding/querying PNG images. This is critical on 89 + // the ESP32-C3 where total RAM is ~320 KB. 90 + constexpr size_t PNG_DECODER_APPROX_SIZE = 44 * 1024; // ~42 KB + overhead 91 + constexpr size_t MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom 92 + 93 + // Convert entire source line to grayscale with alpha blending to white background. 94 + // For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards. 95 + // Processing the whole line at once improves cache locality and reduces per-pixel overhead. 96 + void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette, int hasAlpha) { 97 + switch (pixelType) { 98 + case PNG_PIXEL_GRAYSCALE: 99 + memcpy(grayLine, pPixels, width); 100 + break; 101 + 102 + case PNG_PIXEL_TRUECOLOR: 103 + for (int x = 0; x < width; x++) { 104 + uint8_t* p = &pPixels[x * 3]; 105 + grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); 106 + } 107 + break; 108 + 109 + case PNG_PIXEL_INDEXED: 110 + if (palette) { 111 + if (hasAlpha) { 112 + for (int x = 0; x < width; x++) { 113 + uint8_t idx = pPixels[x]; 114 + uint8_t* p = &palette[idx * 3]; 115 + uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); 116 + uint8_t alpha = palette[768 + idx]; 117 + grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255); 118 + } 119 + } else { 120 + for (int x = 0; x < width; x++) { 121 + uint8_t* p = &palette[pPixels[x] * 3]; 122 + grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); 123 + } 124 + } 125 + } else { 126 + memcpy(grayLine, pPixels, width); 127 + } 128 + break; 129 + 130 + case PNG_PIXEL_GRAY_ALPHA: 131 + for (int x = 0; x < width; x++) { 132 + uint8_t gray = pPixels[x * 2]; 133 + uint8_t alpha = pPixels[x * 2 + 1]; 134 + grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255); 135 + } 136 + break; 137 + 138 + case PNG_PIXEL_TRUECOLOR_ALPHA: 139 + for (int x = 0; x < width; x++) { 140 + uint8_t* p = &pPixels[x * 4]; 141 + uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); 142 + uint8_t alpha = p[3]; 143 + grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255); 144 + } 145 + break; 146 + 147 + default: 148 + memset(grayLine, 128, width); 149 + break; 150 + } 151 + } 152 + 153 + int pngDrawCallback(PNGDRAW* pDraw) { 154 + PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser); 155 + if (!ctx || !ctx->config || !ctx->renderer || !ctx->grayLineBuffer) return 0; 156 + 157 + int srcY = pDraw->y; 158 + int srcWidth = ctx->srcWidth; 159 + 160 + // Calculate destination Y with scaling 161 + int dstY = (int)(srcY * ctx->scale); 162 + 163 + // Skip if we already rendered this destination row (multiple source rows map to same dest) 164 + if (dstY == ctx->lastDstY) return 1; 165 + ctx->lastDstY = dstY; 166 + 167 + // Check bounds 168 + if (dstY >= ctx->dstHeight) return 1; 169 + 170 + int outY = ctx->config->y + dstY; 171 + if (outY >= ctx->screenHeight) return 1; 172 + 173 + // Convert entire source line to grayscale (improves cache locality) 174 + convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->pPalette, 175 + pDraw->iHasAlpha); 176 + 177 + // Render scaled row using Bresenham-style integer stepping (no floating-point division) 178 + int dstWidth = ctx->dstWidth; 179 + int outXBase = ctx->config->x; 180 + int screenWidth = ctx->screenWidth; 181 + bool useDithering = ctx->config->useDithering; 182 + bool caching = ctx->caching; 183 + 184 + int srcX = 0; 185 + int error = 0; 186 + 187 + for (int dstX = 0; dstX < dstWidth; dstX++) { 188 + int outX = outXBase + dstX; 189 + if (outX < screenWidth) { 190 + uint8_t gray = ctx->grayLineBuffer[srcX]; 191 + 192 + uint8_t ditheredGray; 193 + if (useDithering) { 194 + ditheredGray = applyBayerDither4Level(gray, outX, outY); 195 + } else { 196 + ditheredGray = gray / 85; 197 + if (ditheredGray > 3) ditheredGray = 3; 198 + } 199 + drawPixelWithRenderMode(*ctx->renderer, outX, outY, ditheredGray); 200 + if (caching) ctx->cache.setPixel(outX, outY, ditheredGray); 201 + } 202 + 203 + // Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth 204 + error += srcWidth; 205 + while (error >= dstWidth) { 206 + error -= dstWidth; 207 + srcX++; 208 + } 209 + } 210 + 211 + return 1; 212 + } 213 + 214 + } // namespace 215 + 216 + bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) { 217 + size_t freeHeap = ESP.getFreeHeap(); 218 + if (freeHeap < MIN_FREE_HEAP_FOR_PNG) { 219 + Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap, 220 + MIN_FREE_HEAP_FOR_PNG); 221 + return false; 222 + } 223 + 224 + PNG* png = new (std::nothrow) PNG(); 225 + if (!png) { 226 + Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder for dimensions\n", millis()); 227 + return false; 228 + } 229 + 230 + int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle, 231 + nullptr); 232 + 233 + if (rc != 0) { 234 + Serial.printf("[%lu] [PNG] Failed to open PNG for dimensions: %d\n", millis(), rc); 235 + delete png; 236 + return false; 237 + } 238 + 239 + out.width = png->getWidth(); 240 + out.height = png->getHeight(); 241 + 242 + png->close(); 243 + delete png; 244 + return true; 245 + } 246 + 247 + bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, 248 + const RenderConfig& config) { 249 + Serial.printf("[%lu] [PNG] Decoding PNG: %s\n", millis(), imagePath.c_str()); 250 + 251 + size_t freeHeap = ESP.getFreeHeap(); 252 + if (freeHeap < MIN_FREE_HEAP_FOR_PNG) { 253 + Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap, 254 + MIN_FREE_HEAP_FOR_PNG); 255 + return false; 256 + } 257 + 258 + // Heap-allocate PNG decoder (~42 KB) - freed at end of function 259 + PNG* png = new (std::nothrow) PNG(); 260 + if (!png) { 261 + Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder\n", millis()); 262 + return false; 263 + } 264 + 265 + PngContext ctx; 266 + ctx.renderer = &renderer; 267 + ctx.config = &config; 268 + ctx.screenWidth = renderer.getScreenWidth(); 269 + ctx.screenHeight = renderer.getScreenHeight(); 270 + 271 + int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle, 272 + pngDrawCallback); 273 + if (rc != PNG_SUCCESS) { 274 + Serial.printf("[%lu] [PNG] Failed to open PNG: %d\n", millis(), rc); 275 + delete png; 276 + return false; 277 + } 278 + 279 + if (!validateImageDimensions(png->getWidth(), png->getHeight(), "PNG")) { 280 + png->close(); 281 + delete png; 282 + return false; 283 + } 284 + 285 + // Calculate output dimensions 286 + ctx.srcWidth = png->getWidth(); 287 + ctx.srcHeight = png->getHeight(); 288 + 289 + if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) { 290 + // Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes) 291 + ctx.dstWidth = config.maxWidth; 292 + ctx.dstHeight = config.maxHeight; 293 + ctx.scale = (float)ctx.dstWidth / ctx.srcWidth; 294 + } else { 295 + // Calculate scale factor to fit within maxWidth/maxHeight 296 + float scaleX = (float)config.maxWidth / ctx.srcWidth; 297 + float scaleY = (float)config.maxHeight / ctx.srcHeight; 298 + ctx.scale = (scaleX < scaleY) ? scaleX : scaleY; 299 + if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale 300 + 301 + ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale); 302 + ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale); 303 + } 304 + ctx.lastDstY = -1; // Reset row tracking 305 + 306 + Serial.printf("[%lu] [PNG] PNG %dx%d -> %dx%d (scale %.2f), bpp: %d\n", millis(), ctx.srcWidth, ctx.srcHeight, 307 + ctx.dstWidth, ctx.dstHeight, ctx.scale, png->getBpp()); 308 + 309 + if (png->getBpp() != 8) { 310 + warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath); 311 + } 312 + 313 + // Allocate grayscale line buffer on demand (~3.2 KB) - freed after decode 314 + const size_t grayBufSize = PNG_MAX_BUFFERED_PIXELS / 2; 315 + ctx.grayLineBuffer = static_cast<uint8_t*>(malloc(grayBufSize)); 316 + if (!ctx.grayLineBuffer) { 317 + Serial.printf("[%lu] [PNG] Failed to allocate gray line buffer\n", millis()); 318 + png->close(); 319 + delete png; 320 + return false; 321 + } 322 + 323 + // Allocate cache buffer using SCALED dimensions 324 + ctx.caching = !config.cachePath.empty(); 325 + if (ctx.caching) { 326 + if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) { 327 + Serial.printf("[%lu] [PNG] Failed to allocate cache buffer, continuing without caching\n", millis()); 328 + ctx.caching = false; 329 + } 330 + } 331 + 332 + unsigned long decodeStart = millis(); 333 + rc = png->decode(&ctx, 0); 334 + unsigned long decodeTime = millis() - decodeStart; 335 + 336 + free(ctx.grayLineBuffer); 337 + ctx.grayLineBuffer = nullptr; 338 + 339 + if (rc != PNG_SUCCESS) { 340 + Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc); 341 + png->close(); 342 + delete png; 343 + return false; 344 + } 345 + 346 + png->close(); 347 + delete png; 348 + Serial.printf("[%lu] [PNG] PNG decoding complete - render time: %lu ms\n", millis(), decodeTime); 349 + 350 + // Write cache file if caching was enabled and buffer was allocated 351 + if (ctx.caching) { 352 + ctx.cache.writeToFile(config.cachePath); 353 + } 354 + 355 + return true; 356 + } 357 + 358 + bool PngToFramebufferConverter::supportsFormat(const std::string& extension) { 359 + std::string ext = extension; 360 + for (auto& c : ext) { 361 + c = tolower(c); 362 + } 363 + return (ext == ".png"); 364 + }
+17
lib/Epub/Epub/converters/PngToFramebufferConverter.h
··· 1 + #pragma once 2 + 3 + #include "ImageToFramebufferDecoder.h" 4 + 5 + class PngToFramebufferConverter final : public ImageToFramebufferDecoder { 6 + public: 7 + static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out); 8 + 9 + bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override; 10 + 11 + bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override { 12 + return getDimensionsStatic(imagePath, dims); 13 + } 14 + 15 + static bool supportsFormat(const std::string& extension); 16 + const char* getFormatName() const override { return "PNG"; } 17 + };
+116 -17
lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp
··· 1 1 #include "ChapterHtmlSlimParser.h" 2 2 3 + #include <FsHelpers.h> 3 4 #include <GfxRenderer.h> 4 5 #include <HalStorage.h> 5 6 #include <Logging.h> 6 7 #include <expat.h> 7 8 9 + #include "../../Epub.h" 8 10 #include "../Page.h" 11 + #include "../converters/ImageDecoderFactory.h" 12 + #include "../converters/ImageToFramebufferDecoder.h" 9 13 #include "../htmlEntities.h" 10 14 11 15 const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; ··· 156 160 } 157 161 158 162 if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { 159 - // TODO: Start processing image tags 160 - std::string alt = "[Image]"; 163 + std::string src; 164 + std::string alt; 161 165 if (atts != nullptr) { 162 166 for (int i = 0; atts[i]; i += 2) { 163 - if (strcmp(atts[i], "alt") == 0) { 164 - if (strlen(atts[i + 1]) > 0) { 165 - alt = "[Image: " + std::string(atts[i + 1]) + "]"; 167 + if (strcmp(atts[i], "src") == 0) { 168 + src = atts[i + 1]; 169 + } else if (strcmp(atts[i], "alt") == 0) { 170 + alt = atts[i + 1]; 171 + } 172 + } 173 + 174 + if (!src.empty()) { 175 + LOG_DBG("EHP", "Found image: src=%s", src.c_str()); 176 + 177 + { 178 + // Resolve the image path relative to the HTML file 179 + std::string resolvedPath = FsHelpers::normalisePath(self->contentBase + src); 180 + 181 + // Create a unique filename for the cached image 182 + std::string ext; 183 + size_t extPos = resolvedPath.rfind('.'); 184 + if (extPos != std::string::npos) { 185 + ext = resolvedPath.substr(extPos); 186 + } 187 + std::string cachedImagePath = self->imageBasePath + std::to_string(self->imageCounter++) + ext; 188 + 189 + // Extract image to cache file 190 + FsFile cachedImageFile; 191 + bool extractSuccess = false; 192 + if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) { 193 + extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096); 194 + cachedImageFile.flush(); 195 + cachedImageFile.close(); 196 + delay(50); // Give SD card time to sync 197 + } 198 + 199 + if (extractSuccess) { 200 + // Get image dimensions 201 + ImageDimensions dims = {0, 0}; 202 + ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath); 203 + if (decoder && decoder->getDimensions(cachedImagePath, dims)) { 204 + LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height); 205 + 206 + // Scale to fit viewport while maintaining aspect ratio 207 + int maxWidth = self->viewportWidth; 208 + int maxHeight = self->viewportHeight; 209 + float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f; 210 + float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f; 211 + float scale = (scaleX < scaleY) ? scaleX : scaleY; 212 + if (scale > 1.0f) scale = 1.0f; 213 + 214 + int displayWidth = (int)(dims.width * scale); 215 + int displayHeight = (int)(dims.height * scale); 216 + 217 + LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale); 218 + 219 + // Create page for image - only break if image won't fit remaining space 220 + if (self->currentPage && !self->currentPage->elements.empty() && 221 + (self->currentPageNextY + displayHeight > self->viewportHeight)) { 222 + self->completePageFn(std::move(self->currentPage)); 223 + self->currentPage.reset(new Page()); 224 + if (!self->currentPage) { 225 + LOG_ERR("EHP", "Failed to create new page"); 226 + return; 227 + } 228 + self->currentPageNextY = 0; 229 + } else if (!self->currentPage) { 230 + self->currentPage.reset(new Page()); 231 + if (!self->currentPage) { 232 + LOG_ERR("EHP", "Failed to create initial page"); 233 + return; 234 + } 235 + self->currentPageNextY = 0; 236 + } 237 + 238 + // Create ImageBlock and add to page 239 + auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight); 240 + if (!imageBlock) { 241 + LOG_ERR("EHP", "Failed to create ImageBlock"); 242 + return; 243 + } 244 + int xPos = (self->viewportWidth - displayWidth) / 2; 245 + auto pageImage = std::make_shared<PageImage>(imageBlock, xPos, self->currentPageNextY); 246 + if (!pageImage) { 247 + LOG_ERR("EHP", "Failed to create PageImage"); 248 + return; 249 + } 250 + self->currentPage->elements.push_back(pageImage); 251 + self->currentPageNextY += displayHeight; 252 + 253 + self->depth += 1; 254 + return; 255 + } else { 256 + LOG_ERR("EHP", "Failed to get image dimensions"); 257 + Storage.remove(cachedImagePath.c_str()); 258 + } 259 + } else { 260 + LOG_ERR("EHP", "Failed to extract image"); 166 261 } 167 - break; 168 262 } 169 263 } 170 - } 171 - 172 - LOG_DBG("EHP", "Image alt: %s", alt.c_str()); 173 264 174 - self->startNewTextBlock(centeredBlockStyle); 175 - self->italicUntilDepth = min(self->italicUntilDepth, self->depth); 176 - // Advance depth before processing character data (like you would for an element with text) 177 - self->depth += 1; 178 - self->characterData(userData, alt.c_str(), alt.length()); 265 + // Fallback to alt text if image processing fails 266 + if (!alt.empty()) { 267 + alt = "[Image: " + alt + "]"; 268 + self->startNewTextBlock(centeredBlockStyle); 269 + self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); 270 + self->depth += 1; 271 + self->characterData(userData, alt.c_str(), alt.length()); 272 + // Skip any child content (skip until parent as we pre-advanced depth above) 273 + self->skipUntilDepth = self->depth - 1; 274 + return; 275 + } 179 276 180 - // Skip table contents (skip until parent as we pre-advanced depth above) 181 - self->skipUntilDepth = self->depth - 1; 182 - return; 277 + // No alt text, skip 278 + self->skipUntilDepth = self->depth; 279 + self->depth += 1; 280 + return; 281 + } 183 282 } 184 283 185 284 if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) {
+15 -5
lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h
··· 7 7 #include <memory> 8 8 9 9 #include "../ParsedText.h" 10 + #include "../blocks/ImageBlock.h" 10 11 #include "../blocks/TextBlock.h" 11 12 #include "../css/CssParser.h" 12 13 #include "../css/CssStyle.h" 13 14 14 15 class Page; 15 16 class GfxRenderer; 17 + class Epub; 16 18 17 19 #define MAX_WORD_SIZE 200 18 20 19 21 class ChapterHtmlSlimParser { 22 + std::shared_ptr<Epub> epub; 20 23 const std::string& filepath; 21 24 GfxRenderer& renderer; 22 25 std::function<void(std::unique_ptr<Page>)> completePageFn; ··· 43 46 bool hyphenationEnabled; 44 47 const CssParser* cssParser; 45 48 bool embeddedStyle; 49 + std::string contentBase; 50 + std::string imageBasePath; 51 + int imageCounter = 0; 46 52 47 53 // Style tracking (replaces depth-based approach) 48 54 struct StyleStackEntry { ··· 68 74 static void XMLCALL endElement(void* userData, const XML_Char* name); 69 75 70 76 public: 71 - explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId, 72 - const float lineCompression, const bool extraParagraphSpacing, 77 + explicit ChapterHtmlSlimParser(std::shared_ptr<Epub> epub, const std::string& filepath, GfxRenderer& renderer, 78 + const int fontId, const float lineCompression, const bool extraParagraphSpacing, 73 79 const uint8_t paragraphAlignment, const uint16_t viewportWidth, 74 80 const uint16_t viewportHeight, const bool hyphenationEnabled, 75 81 const std::function<void(std::unique_ptr<Page>)>& completePageFn, 76 - const bool embeddedStyle, const std::function<void()>& popupFn = nullptr, 82 + const bool embeddedStyle, const std::string& contentBase, 83 + const std::string& imageBasePath, const std::function<void()>& popupFn = nullptr, 77 84 const CssParser* cssParser = nullptr) 78 85 79 - : filepath(filepath), 86 + : epub(epub), 87 + filepath(filepath), 80 88 renderer(renderer), 81 89 fontId(fontId), 82 90 lineCompression(lineCompression), ··· 88 96 completePageFn(completePageFn), 89 97 popupFn(popupFn), 90 98 cssParser(cssParser), 91 - embeddedStyle(embeddedStyle) {} 99 + embeddedStyle(embeddedStyle), 100 + contentBase(contentBase), 101 + imageBasePath(imageBasePath) {} 92 102 93 103 ~ChapterHtmlSlimParser() = default; 94 104 bool parseAndBuildPages();
+1
lib/GfxRenderer/GfxRenderer.h
··· 117 117 118 118 // Grayscale functions 119 119 void setRenderMode(const RenderMode mode) { this->renderMode = mode; } 120 + RenderMode getRenderMode() const { return renderMode; } 120 121 void copyGrayscaleLsbBuffers() const; 121 122 void copyGrayscaleMsbBuffers() const; 122 123 void displayGrayBuffer() const;
+4
platformio.ini
··· 30 30 -std=gnu++2a 31 31 # Enable UTF-8 long file names in SdFat 32 32 -DUSE_UTF8_LONG_NAMES=1 33 + # Increase PNG scanline buffer to support up to 800px wide images 34 + # Default is (320*4+1)*2=2562, we need more for larger images 35 + -DPNG_MAX_BUFFERED_PIXELS=6402 33 36 34 37 build_unflags = 35 38 -std=gnu++11 ··· 50 53 SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager 51 54 bblanchon/ArduinoJson @ 7.4.2 52 55 ricmoo/QRCode @ 0.0.1 56 + bitbank2/PNGdec @ ^1.0.0 53 57 links2004/WebSockets @ 2.7.3 54 58 55 59 [env:default]
+700
scripts/generate_test_epub.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Generate test EPUBs for image rendering verification. 4 + 5 + Creates EPUBs with annotated JPEG and PNG images to verify: 6 + - Grayscale rendering (4 levels) 7 + - Image scaling 8 + - Image centering 9 + - Cache performance 10 + - Page serialization 11 + """ 12 + 13 + import os 14 + import zipfile 15 + from pathlib import Path 16 + 17 + try: 18 + from PIL import Image, ImageDraw, ImageFont 19 + except ImportError: 20 + print("Please install Pillow: pip install Pillow") 21 + exit(1) 22 + 23 + OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs" 24 + SCREEN_WIDTH = 480 25 + SCREEN_HEIGHT = 800 26 + 27 + def get_font(size=20): 28 + """Get a font, falling back to default if needed.""" 29 + try: 30 + return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size) 31 + except: 32 + try: 33 + return ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSans.ttf", size) 34 + except: 35 + return ImageFont.load_default() 36 + 37 + def draw_text_centered(draw, y, text, font, fill=0): 38 + """Draw centered text at given y position.""" 39 + bbox = draw.textbbox((0, 0), text, font=font) 40 + text_width = bbox[2] - bbox[0] 41 + x = (draw.im.size[0] - text_width) // 2 42 + draw.text((x, y), text, font=font, fill=fill) 43 + 44 + def draw_text_wrapped(draw, x, y, text, font, max_width, fill=0): 45 + """Draw text with word wrapping.""" 46 + words = text.split() 47 + lines = [] 48 + current_line = [] 49 + 50 + for word in words: 51 + test_line = ' '.join(current_line + [word]) 52 + bbox = draw.textbbox((0, 0), test_line, font=font) 53 + if bbox[2] - bbox[0] <= max_width: 54 + current_line.append(word) 55 + else: 56 + if current_line: 57 + lines.append(' '.join(current_line)) 58 + current_line = [word] 59 + if current_line: 60 + lines.append(' '.join(current_line)) 61 + 62 + line_height = font.size + 4 if hasattr(font, 'size') else 20 63 + for i, line in enumerate(lines): 64 + draw.text((x, y + i * line_height), line, font=font, fill=fill) 65 + 66 + return len(lines) * line_height 67 + 68 + def create_grayscale_test_image(filename, is_png=True): 69 + """ 70 + Create image with 4 grayscale squares to verify 4-level rendering. 71 + """ 72 + width, height = 400, 600 73 + img = Image.new('L', (width, height), 255) 74 + draw = ImageDraw.Draw(img) 75 + font = get_font(16) 76 + font_small = get_font(14) 77 + 78 + # Title 79 + draw_text_centered(draw, 10, "GRAYSCALE TEST", font, fill=0) 80 + draw_text_centered(draw, 35, "Verify 4 distinct gray levels", font_small, fill=64) 81 + 82 + # Draw 4 grayscale squares 83 + square_size = 70 84 + start_y = 65 85 + gap = 10 86 + 87 + # Gray levels chosen to avoid Bayer dithering threshold boundaries (±40 dither offset) 88 + # Thresholds at 64, 128, 192 - use values in the middle of each band for solid output 89 + # Safe zones: 0-23 (black), 88-103 (dark gray), 152-167 (light gray), 232-255 (white) 90 + levels = [ 91 + (0, "Level 0: BLACK"), 92 + (96, "Level 1: DARK GRAY"), 93 + (160, "Level 2: LIGHT GRAY"), 94 + (255, "Level 3: WHITE"), 95 + ] 96 + 97 + for i, (gray_value, label) in enumerate(levels): 98 + y = start_y + i * (square_size + gap + 22) 99 + x = (width - square_size) // 2 100 + 101 + # Draw square with border 102 + draw.rectangle([x-2, y-2, x + square_size + 2, y + square_size + 2], fill=0) 103 + draw.rectangle([x, y, x + square_size, y + square_size], fill=gray_value) 104 + 105 + # Label below square 106 + bbox = draw.textbbox((0, 0), label, font=font_small) 107 + label_width = bbox[2] - bbox[0] 108 + draw.text(((width - label_width) // 2, y + square_size + 5), label, font=font_small, fill=0) 109 + 110 + # Instructions at bottom (well below the last square) 111 + y = height - 70 112 + draw_text_centered(draw, y, "PASS: 4 distinct shades visible", font_small, fill=0) 113 + draw_text_centered(draw, y + 20, "FAIL: Only black/white or", font_small, fill=64) 114 + draw_text_centered(draw, y + 38, "muddy/indistinct grays", font_small, fill=64) 115 + 116 + # Save 117 + if is_png: 118 + img.save(filename, 'PNG') 119 + else: 120 + img.save(filename, 'JPEG', quality=95) 121 + 122 + def create_centering_test_image(filename, is_png=True): 123 + """ 124 + Create image with border markers to verify centering. 125 + """ 126 + width, height = 350, 400 127 + img = Image.new('L', (width, height), 255) 128 + draw = ImageDraw.Draw(img) 129 + font = get_font(16) 130 + font_small = get_font(14) 131 + 132 + # Draw border 133 + draw.rectangle([0, 0, width-1, height-1], outline=0, width=3) 134 + 135 + # Corner markers 136 + marker_size = 20 137 + for x, y in [(0, 0), (width-marker_size, 0), (0, height-marker_size), (width-marker_size, height-marker_size)]: 138 + draw.rectangle([x, y, x+marker_size, y+marker_size], fill=0) 139 + 140 + # Center cross 141 + cx, cy = width // 2, height // 2 142 + draw.line([cx - 30, cy, cx + 30, cy], fill=0, width=2) 143 + draw.line([cx, cy - 30, cx, cy + 30], fill=0, width=2) 144 + 145 + # Title 146 + draw_text_centered(draw, 40, "CENTERING TEST", font, fill=0) 147 + 148 + # Instructions 149 + y = 80 150 + draw_text_centered(draw, y, "Image should be centered", font_small, fill=0) 151 + draw_text_centered(draw, y + 20, "horizontally on screen", font_small, fill=0) 152 + 153 + y = 150 154 + draw_text_centered(draw, y, "Check:", font_small, fill=0) 155 + draw_text_centered(draw, y + 25, "- Equal margins left & right", font_small, fill=64) 156 + draw_text_centered(draw, y + 45, "- All 4 corners visible", font_small, fill=64) 157 + draw_text_centered(draw, y + 65, "- Border is complete rectangle", font_small, fill=64) 158 + 159 + # Pass/fail 160 + y = height - 80 161 + draw_text_centered(draw, y, "PASS: Centered, all corners visible", font_small, fill=0) 162 + draw_text_centered(draw, y + 20, "FAIL: Off-center or cropped", font_small, fill=64) 163 + 164 + if is_png: 165 + img.save(filename, 'PNG') 166 + else: 167 + img.save(filename, 'JPEG', quality=95) 168 + 169 + def create_scaling_test_image(filename, is_png=True): 170 + """ 171 + Create large image to verify scaling works. 172 + """ 173 + # Make image larger than screen but within decoder limits (max 2048x1536) 174 + width, height = 1200, 1500 175 + img = Image.new('L', (width, height), 240) 176 + draw = ImageDraw.Draw(img) 177 + font = get_font(48) 178 + font_medium = get_font(32) 179 + font_small = get_font(24) 180 + 181 + # Border 182 + draw.rectangle([0, 0, width-1, height-1], outline=0, width=8) 183 + draw.rectangle([20, 20, width-21, height-21], outline=128, width=4) 184 + 185 + # Title 186 + draw_text_centered(draw, 60, "SCALING TEST", font, fill=0) 187 + draw_text_centered(draw, 130, f"Original: {width}x{height} (larger than screen)", font_medium, fill=64) 188 + 189 + # Grid pattern to verify scaling quality 190 + grid_start_y = 220 191 + grid_size = 400 192 + cell_size = 50 193 + 194 + draw_text_centered(draw, grid_start_y - 40, "Grid pattern (check for artifacts):", font_small, fill=0) 195 + 196 + grid_x = (width - grid_size) // 2 197 + for row in range(grid_size // cell_size): 198 + for col in range(grid_size // cell_size): 199 + x = grid_x + col * cell_size 200 + y = grid_start_y + row * cell_size 201 + if (row + col) % 2 == 0: 202 + draw.rectangle([x, y, x + cell_size, y + cell_size], fill=0) 203 + else: 204 + draw.rectangle([x, y, x + cell_size, y + cell_size], fill=200) 205 + 206 + # Size indicator bars 207 + y = grid_start_y + grid_size + 60 208 + draw_text_centered(draw, y, "Width markers (should fit on screen):", font_small, fill=0) 209 + 210 + bar_y = y + 40 211 + # Full width bar 212 + draw.rectangle([50, bar_y, width - 50, bar_y + 30], fill=0) 213 + draw.text((60, bar_y + 5), "FULL WIDTH", font=font_small, fill=255) 214 + 215 + # Half width bar 216 + bar_y += 60 217 + half_start = width // 4 218 + draw.rectangle([half_start, bar_y, width - half_start, bar_y + 30], fill=85) 219 + draw.text((half_start + 10, bar_y + 5), "HALF WIDTH", font=font_small, fill=255) 220 + 221 + # Instructions 222 + y = height - 350 223 + draw_text_centered(draw, y, "VERIFICATION:", font_medium, fill=0) 224 + y += 50 225 + instructions = [ 226 + "1. Image fits within screen bounds", 227 + "2. All borders visible (not cropped)", 228 + "3. Grid pattern clear (no moire)", 229 + "4. Text readable after scaling", 230 + "5. Aspect ratio preserved (not stretched)", 231 + ] 232 + for i, text in enumerate(instructions): 233 + draw_text_centered(draw, y + i * 35, text, font_small, fill=64) 234 + 235 + y = height - 100 236 + draw_text_centered(draw, y, "PASS: Scaled down, readable, complete", font_small, fill=0) 237 + draw_text_centered(draw, y + 30, "FAIL: Cropped, distorted, or unreadable", font_small, fill=64) 238 + 239 + if is_png: 240 + img.save(filename, 'PNG') 241 + else: 242 + img.save(filename, 'JPEG', quality=95) 243 + 244 + def create_wide_scaling_test_image(filename, is_png=True): 245 + """ 246 + Create wide image (1807x736) to test scaling with specific dimensions 247 + that can trigger cache dimension mismatches due to floating-point rounding. 248 + """ 249 + width, height = 1807, 736 250 + img = Image.new('L', (width, height), 240) 251 + draw = ImageDraw.Draw(img) 252 + font = get_font(48) 253 + font_medium = get_font(32) 254 + font_small = get_font(24) 255 + 256 + # Border 257 + draw.rectangle([0, 0, width-1, height-1], outline=0, width=6) 258 + draw.rectangle([15, 15, width-16, height-16], outline=128, width=3) 259 + 260 + # Title 261 + draw_text_centered(draw, 40, "WIDE SCALING TEST", font, fill=0) 262 + draw_text_centered(draw, 100, f"Original: {width}x{height} (tests rounding edge case)", font_medium, fill=64) 263 + 264 + # Grid pattern to verify scaling quality 265 + grid_start_x = 100 266 + grid_start_y = 180 267 + grid_width = 600 268 + grid_height = 300 269 + cell_size = 50 270 + 271 + draw.text((grid_start_x, grid_start_y - 35), "Grid pattern (check for artifacts):", font=font_small, fill=0) 272 + 273 + for row in range(grid_height // cell_size): 274 + for col in range(grid_width // cell_size): 275 + x = grid_start_x + col * cell_size 276 + y = grid_start_y + row * cell_size 277 + if (row + col) % 2 == 0: 278 + draw.rectangle([x, y, x + cell_size, y + cell_size], fill=0) 279 + else: 280 + draw.rectangle([x, y, x + cell_size, y + cell_size], fill=200) 281 + 282 + # Verification section on the right 283 + text_x = 800 284 + text_y = 180 285 + draw.text((text_x, text_y), "VERIFICATION:", font=font_medium, fill=0) 286 + text_y += 50 287 + instructions = [ 288 + "1. Image fits within screen", 289 + "2. All borders visible", 290 + "3. Grid pattern clear", 291 + "4. Text readable", 292 + "5. No double-decode in log", 293 + ] 294 + for i, text in enumerate(instructions): 295 + draw.text((text_x, text_y + i * 35), text, font=font_small, fill=64) 296 + 297 + # Dimension info 298 + draw.text((text_x, 450), f"Dimensions: {width}x{height}", font=font_small, fill=0) 299 + draw.text((text_x, 485), "Tests cache dimension matching", font=font_small, fill=64) 300 + 301 + # Pass/fail at bottom 302 + y = height - 80 303 + draw_text_centered(draw, y, "PASS: Single decode, cached correctly", font_small, fill=0) 304 + draw_text_centered(draw, y + 30, "FAIL: Cache mismatch, multiple decodes", font_small, fill=64) 305 + 306 + if is_png: 307 + img.save(filename, 'PNG') 308 + else: 309 + img.save(filename, 'JPEG', quality=95) 310 + 311 + def create_cache_test_image(filename, page_num, is_png=True): 312 + """ 313 + Create image for cache performance testing. 314 + """ 315 + width, height = 400, 300 316 + img = Image.new('L', (width, height), 255) 317 + draw = ImageDraw.Draw(img) 318 + font = get_font(18) 319 + font_small = get_font(14) 320 + font_large = get_font(36) 321 + 322 + # Border 323 + draw.rectangle([0, 0, width-1, height-1], outline=0, width=2) 324 + 325 + # Page number prominent 326 + draw_text_centered(draw, 30, f"CACHE TEST PAGE {page_num}", font, fill=0) 327 + draw_text_centered(draw, 80, f"#{page_num}", font_large, fill=0) 328 + 329 + # Instructions 330 + y = 140 331 + draw_text_centered(draw, y, "Navigate away then return", font_small, fill=64) 332 + draw_text_centered(draw, y + 25, "Second load should be faster", font_small, fill=64) 333 + 334 + y = 220 335 + draw_text_centered(draw, y, "PASS: Faster reload from cache", font_small, fill=0) 336 + draw_text_centered(draw, y + 20, "FAIL: Same slow decode each time", font_small, fill=64) 337 + 338 + if is_png: 339 + img.save(filename, 'PNG') 340 + else: 341 + img.save(filename, 'JPEG', quality=95) 342 + 343 + def create_gradient_test_image(filename, is_png=True): 344 + """ 345 + Create horizontal gradient to test grayscale banding. 346 + """ 347 + width, height = 400, 500 348 + img = Image.new('L', (width, height), 255) 349 + draw = ImageDraw.Draw(img) 350 + font = get_font(16) 351 + font_small = get_font(14) 352 + 353 + draw_text_centered(draw, 10, "GRADIENT TEST", font, fill=0) 354 + draw_text_centered(draw, 35, "Smooth gradient → 4 bands expected", font_small, fill=64) 355 + 356 + # Horizontal gradient 357 + gradient_y = 70 358 + gradient_height = 100 359 + for x in range(width): 360 + gray = int(255 * x / width) 361 + draw.line([(x, gradient_y), (x, gradient_y + gradient_height)], fill=gray) 362 + 363 + # Border around gradient 364 + draw.rectangle([0, gradient_y-1, width-1, gradient_y + gradient_height + 1], outline=0, width=1) 365 + 366 + # Labels 367 + y = gradient_y + gradient_height + 10 368 + draw.text((5, y), "BLACK", font=font_small, fill=0) 369 + draw.text((width - 50, y), "WHITE", font=font_small, fill=0) 370 + 371 + # 4-step gradient (what it should look like) 372 + y = 220 373 + draw_text_centered(draw, y, "Expected result (4 distinct bands):", font_small, fill=0) 374 + 375 + band_y = y + 25 376 + band_height = 60 377 + band_width = width // 4 378 + for i, gray in enumerate([0, 85, 170, 255]): 379 + x = i * band_width 380 + draw.rectangle([x, band_y, x + band_width, band_y + band_height], fill=gray) 381 + draw.rectangle([0, band_y-1, width-1, band_y + band_height + 1], outline=0, width=1) 382 + 383 + # Vertical gradient 384 + y = 340 385 + draw_text_centered(draw, y, "Vertical gradient:", font_small, fill=0) 386 + 387 + vgrad_y = y + 25 388 + vgrad_height = 80 389 + for row in range(vgrad_height): 390 + gray = int(255 * row / vgrad_height) 391 + draw.line([(50, vgrad_y + row), (width - 50, vgrad_y + row)], fill=gray) 392 + draw.rectangle([49, vgrad_y-1, width-49, vgrad_y + vgrad_height + 1], outline=0, width=1) 393 + 394 + # Pass/fail 395 + y = height - 50 396 + draw_text_centered(draw, y, "PASS: Clear 4-band quantization", font_small, fill=0) 397 + draw_text_centered(draw, y + 20, "FAIL: Binary/noisy dithering", font_small, fill=64) 398 + 399 + if is_png: 400 + img.save(filename, 'PNG') 401 + else: 402 + img.save(filename, 'JPEG', quality=95) 403 + 404 + def create_format_test_image(filename, format_name, is_png=True): 405 + """ 406 + Create simple image to verify format support. 407 + """ 408 + width, height = 350, 250 409 + img = Image.new('L', (width, height), 255) 410 + draw = ImageDraw.Draw(img) 411 + font = get_font(20) 412 + font_large = get_font(36) 413 + font_small = get_font(14) 414 + 415 + # Border 416 + draw.rectangle([0, 0, width-1, height-1], outline=0, width=3) 417 + 418 + # Format name 419 + draw_text_centered(draw, 30, f"{format_name} FORMAT TEST", font, fill=0) 420 + draw_text_centered(draw, 80, format_name, font_large, fill=0) 421 + 422 + # Checkmark area 423 + y = 140 424 + draw_text_centered(draw, y, "If you can read this,", font_small, fill=64) 425 + draw_text_centered(draw, y + 20, f"{format_name} decoding works!", font_small, fill=64) 426 + 427 + y = height - 40 428 + draw_text_centered(draw, y, f"PASS: {format_name} image visible", font_small, fill=0) 429 + 430 + if is_png: 431 + img.save(filename, 'PNG') 432 + else: 433 + img.save(filename, 'JPEG', quality=95) 434 + 435 + def create_epub(epub_path, title, chapters): 436 + """ 437 + Create an EPUB file with the given chapters. 438 + 439 + chapters: list of (chapter_title, html_content, images) 440 + images: list of (image_filename, image_data) 441 + """ 442 + with zipfile.ZipFile(epub_path, 'w', zipfile.ZIP_DEFLATED) as epub: 443 + # mimetype (must be first, uncompressed) 444 + epub.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED) 445 + 446 + # Container 447 + container_xml = '''<?xml version="1.0" encoding="UTF-8"?> 448 + <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"> 449 + <rootfiles> 450 + <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/> 451 + </rootfiles> 452 + </container>''' 453 + epub.writestr('META-INF/container.xml', container_xml) 454 + 455 + # Collect all images and chapters 456 + manifest_items = [] 457 + spine_items = [] 458 + 459 + # Add chapters and images 460 + for i, (chapter_title, html_content, images) in enumerate(chapters): 461 + chapter_id = f'chapter{i+1}' 462 + chapter_file = f'chapter{i+1}.xhtml' 463 + 464 + # Add images for this chapter 465 + for img_filename, img_data in images: 466 + media_type = 'image/png' if img_filename.endswith('.png') else 'image/jpeg' 467 + manifest_items.append(f' <item id="{img_filename.replace(".", "_")}" href="images/{img_filename}" media-type="{media_type}"/>') 468 + epub.writestr(f'OEBPS/images/{img_filename}', img_data) 469 + 470 + # Add chapter 471 + manifest_items.append(f' <item id="{chapter_id}" href="{chapter_file}" media-type="application/xhtml+xml"/>') 472 + spine_items.append(f' <itemref idref="{chapter_id}"/>') 473 + epub.writestr(f'OEBPS/{chapter_file}', html_content) 474 + 475 + # content.opf 476 + content_opf = f'''<?xml version="1.0" encoding="UTF-8"?> 477 + <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid"> 478 + <metadata xmlns:dc="http://purl.org/dc/elements/1.1/"> 479 + <dc:identifier id="uid">test-epub-{title.lower().replace(" ", "-")}</dc:identifier> 480 + <dc:title>{title}</dc:title> 481 + <dc:language>en</dc:language> 482 + </metadata> 483 + <manifest> 484 + <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/> 485 + {chr(10).join(manifest_items)} 486 + </manifest> 487 + <spine> 488 + {chr(10).join(spine_items)} 489 + </spine> 490 + </package>''' 491 + epub.writestr('OEBPS/content.opf', content_opf) 492 + 493 + # Navigation document 494 + nav_items = '\n'.join([f' <li><a href="chapter{i+1}.xhtml">{chapters[i][0]}</a></li>' 495 + for i in range(len(chapters))]) 496 + nav_xhtml = f'''<?xml version="1.0" encoding="UTF-8"?> 497 + <!DOCTYPE html> 498 + <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"> 499 + <head><title>Navigation</title></head> 500 + <body> 501 + <nav epub:type="toc"> 502 + <h1>Contents</h1> 503 + <ol> 504 + {nav_items} 505 + </ol> 506 + </nav> 507 + </body> 508 + </html>''' 509 + epub.writestr('OEBPS/nav.xhtml', nav_xhtml) 510 + 511 + def make_chapter(title, body_content): 512 + """Create XHTML chapter content.""" 513 + return f'''<?xml version="1.0" encoding="UTF-8"?> 514 + <!DOCTYPE html> 515 + <html xmlns="http://www.w3.org/1999/xhtml"> 516 + <head><title>{title}</title></head> 517 + <body> 518 + <h1>{title}</h1> 519 + {body_content} 520 + </body> 521 + </html>''' 522 + 523 + def main(): 524 + OUTPUT_DIR.mkdir(exist_ok=True) 525 + 526 + # Temp directory for images 527 + import tempfile 528 + with tempfile.TemporaryDirectory() as tmpdir: 529 + tmpdir = Path(tmpdir) 530 + 531 + print("Generating test images...") 532 + 533 + # Generate all test images 534 + images = {} 535 + 536 + # JPEG tests 537 + create_grayscale_test_image(tmpdir / 'grayscale_test.jpg', is_png=False) 538 + create_centering_test_image(tmpdir / 'centering_test.jpg', is_png=False) 539 + create_scaling_test_image(tmpdir / 'scaling_test.jpg', is_png=False) 540 + create_wide_scaling_test_image(tmpdir / 'wide_scaling_test.jpg', is_png=False) 541 + create_gradient_test_image(tmpdir / 'gradient_test.jpg', is_png=False) 542 + create_format_test_image(tmpdir / 'jpeg_format.jpg', 'JPEG', is_png=False) 543 + create_cache_test_image(tmpdir / 'cache_test_1.jpg', 1, is_png=False) 544 + create_cache_test_image(tmpdir / 'cache_test_2.jpg', 2, is_png=False) 545 + 546 + # PNG tests 547 + create_grayscale_test_image(tmpdir / 'grayscale_test.png', is_png=True) 548 + create_centering_test_image(tmpdir / 'centering_test.png', is_png=True) 549 + create_scaling_test_image(tmpdir / 'scaling_test.png', is_png=True) 550 + create_wide_scaling_test_image(tmpdir / 'wide_scaling_test.png', is_png=True) 551 + create_gradient_test_image(tmpdir / 'gradient_test.png', is_png=True) 552 + create_format_test_image(tmpdir / 'png_format.png', 'PNG', is_png=True) 553 + create_cache_test_image(tmpdir / 'cache_test_1.png', 1, is_png=True) 554 + create_cache_test_image(tmpdir / 'cache_test_2.png', 2, is_png=True) 555 + 556 + # Read all images 557 + for img_file in tmpdir.glob('*.*'): 558 + images[img_file.name] = img_file.read_bytes() 559 + 560 + print("Creating JPEG test EPUB...") 561 + jpeg_chapters = [ 562 + ("Introduction", make_chapter("JPEG Image Tests", """ 563 + <p>This EPUB tests JPEG image rendering.</p> 564 + <p>Navigate through chapters to verify each test case.</p> 565 + <p><strong>Test Plan:</strong></p> 566 + <ul> 567 + <li>Grayscale rendering (4 levels)</li> 568 + <li>Image centering</li> 569 + <li>Large image scaling</li> 570 + <li>Cache performance</li> 571 + </ul> 572 + """), []), 573 + ("1. JPEG Format", make_chapter("JPEG Format Test", """ 574 + <p>Basic JPEG decoding test.</p> 575 + <img src="images/jpeg_format.jpg" alt="JPEG format test"/> 576 + <p>If the image above is visible, JPEG decoding works.</p> 577 + """), [('jpeg_format.jpg', images['jpeg_format.jpg'])]), 578 + ("2. Grayscale", make_chapter("Grayscale Test", """ 579 + <p>Verify 4 distinct gray levels are visible.</p> 580 + <img src="images/grayscale_test.jpg" alt="Grayscale test"/> 581 + """), [('grayscale_test.jpg', images['grayscale_test.jpg'])]), 582 + ("3. Gradient", make_chapter("Gradient Test", """ 583 + <p>Verify gradient quantizes to 4 bands.</p> 584 + <img src="images/gradient_test.jpg" alt="Gradient test"/> 585 + """), [('gradient_test.jpg', images['gradient_test.jpg'])]), 586 + ("4. Centering", make_chapter("Centering Test", """ 587 + <p>Verify image is centered horizontally.</p> 588 + <img src="images/centering_test.jpg" alt="Centering test"/> 589 + """), [('centering_test.jpg', images['centering_test.jpg'])]), 590 + ("5. Scaling", make_chapter("Scaling Test", """ 591 + <p>This image is 1200x1500 pixels - larger than the screen.</p> 592 + <p>It should be scaled down to fit.</p> 593 + <img src="images/scaling_test.jpg" alt="Scaling test"/> 594 + """), [('scaling_test.jpg', images['scaling_test.jpg'])]), 595 + ("6. Wide Scaling", make_chapter("Wide Scaling Test", """ 596 + <p>This image is 1807x736 pixels - a wide landscape format.</p> 597 + <p>Tests scaling with dimensions that can cause cache mismatches.</p> 598 + <img src="images/wide_scaling_test.jpg" alt="Wide scaling test"/> 599 + """), [('wide_scaling_test.jpg', images['wide_scaling_test.jpg'])]), 600 + ("7. Cache Test A", make_chapter("Cache Test - Page A", """ 601 + <p>First cache test page. Note the load time.</p> 602 + <img src="images/cache_test_1.jpg" alt="Cache test 1"/> 603 + <p>Navigate to next page, then come back.</p> 604 + """), [('cache_test_1.jpg', images['cache_test_1.jpg'])]), 605 + ("8. Cache Test B", make_chapter("Cache Test - Page B", """ 606 + <p>Second cache test page.</p> 607 + <img src="images/cache_test_2.jpg" alt="Cache test 2"/> 608 + <p>Navigate back to Page A - it should load faster from cache.</p> 609 + """), [('cache_test_2.jpg', images['cache_test_2.jpg'])]), 610 + ] 611 + 612 + create_epub(OUTPUT_DIR / 'test_jpeg_images.epub', 'JPEG Image Tests', jpeg_chapters) 613 + 614 + print("Creating PNG test EPUB...") 615 + png_chapters = [ 616 + ("Introduction", make_chapter("PNG Image Tests", """ 617 + <p>This EPUB tests PNG image rendering.</p> 618 + <p>Navigate through chapters to verify each test case.</p> 619 + <p><strong>Test Plan:</strong></p> 620 + <ul> 621 + <li>PNG decoding (no crash)</li> 622 + <li>Grayscale rendering (4 levels)</li> 623 + <li>Image centering</li> 624 + <li>Large image scaling</li> 625 + </ul> 626 + """), []), 627 + ("1. PNG Format", make_chapter("PNG Format Test", """ 628 + <p>Basic PNG decoding test.</p> 629 + <img src="images/png_format.png" alt="PNG format test"/> 630 + <p>If the image above is visible and no crash occurred, PNG decoding works.</p> 631 + """), [('png_format.png', images['png_format.png'])]), 632 + ("2. Grayscale", make_chapter("Grayscale Test", """ 633 + <p>Verify 4 distinct gray levels are visible.</p> 634 + <img src="images/grayscale_test.png" alt="Grayscale test"/> 635 + """), [('grayscale_test.png', images['grayscale_test.png'])]), 636 + ("3. Gradient", make_chapter("Gradient Test", """ 637 + <p>Verify gradient quantizes to 4 bands.</p> 638 + <img src="images/gradient_test.png" alt="Gradient test"/> 639 + """), [('gradient_test.png', images['gradient_test.png'])]), 640 + ("4. Centering", make_chapter("Centering Test", """ 641 + <p>Verify image is centered horizontally.</p> 642 + <img src="images/centering_test.png" alt="Centering test"/> 643 + """), [('centering_test.png', images['centering_test.png'])]), 644 + ("5. Scaling", make_chapter("Scaling Test", """ 645 + <p>This image is 1200x1500 pixels - larger than the screen.</p> 646 + <p>It should be scaled down to fit.</p> 647 + <img src="images/scaling_test.png" alt="Scaling test"/> 648 + """), [('scaling_test.png', images['scaling_test.png'])]), 649 + ("6. Wide Scaling", make_chapter("Wide Scaling Test", """ 650 + <p>This image is 1807x736 pixels - a wide landscape format.</p> 651 + <p>Tests scaling with dimensions that can cause cache mismatches.</p> 652 + <img src="images/wide_scaling_test.png" alt="Wide scaling test"/> 653 + """), [('wide_scaling_test.png', images['wide_scaling_test.png'])]), 654 + ("7. Cache Test A", make_chapter("Cache Test - Page A", """ 655 + <p>First cache test page. Note the load time.</p> 656 + <img src="images/cache_test_1.png" alt="Cache test 1"/> 657 + <p>Navigate to next page, then come back.</p> 658 + """), [('cache_test_1.png', images['cache_test_1.png'])]), 659 + ("8. Cache Test B", make_chapter("Cache Test - Page B", """ 660 + <p>Second cache test page.</p> 661 + <img src="images/cache_test_2.png" alt="Cache test 2"/> 662 + <p>Navigate back to Page A - it should load faster from cache.</p> 663 + """), [('cache_test_2.png', images['cache_test_2.png'])]), 664 + ] 665 + 666 + create_epub(OUTPUT_DIR / 'test_png_images.epub', 'PNG Image Tests', png_chapters) 667 + 668 + print("Creating mixed format test EPUB...") 669 + mixed_chapters = [ 670 + ("Introduction", make_chapter("Mixed Image Format Tests", """ 671 + <p>This EPUB contains both JPEG and PNG images.</p> 672 + <p>Tests format detection and mixed rendering.</p> 673 + """), []), 674 + ("1. JPEG Image", make_chapter("JPEG in Mixed EPUB", """ 675 + <p>This is a JPEG image:</p> 676 + <img src="images/jpeg_format.jpg" alt="JPEG"/> 677 + """), [('jpeg_format.jpg', images['jpeg_format.jpg'])]), 678 + ("2. PNG Image", make_chapter("PNG in Mixed EPUB", """ 679 + <p>This is a PNG image:</p> 680 + <img src="images/png_format.png" alt="PNG"/> 681 + """), [('png_format.png', images['png_format.png'])]), 682 + ("3. Both Formats", make_chapter("Both Formats on One Page", """ 683 + <p>JPEG image:</p> 684 + <img src="images/grayscale_test.jpg" alt="JPEG grayscale"/> 685 + <p>PNG image:</p> 686 + <img src="images/grayscale_test.png" alt="PNG grayscale"/> 687 + <p>Both should render with proper grayscale.</p> 688 + """), [('grayscale_test.jpg', images['grayscale_test.jpg']), 689 + ('grayscale_test.png', images['grayscale_test.png'])]), 690 + ] 691 + 692 + create_epub(OUTPUT_DIR / 'test_mixed_images.epub', 'Mixed Format Tests', mixed_chapters) 693 + 694 + print(f"\nTest EPUBs created in: {OUTPUT_DIR}") 695 + print("Files:") 696 + for f in OUTPUT_DIR.glob('*.epub'): 697 + print(f" - {f.name}") 698 + 699 + if __name__ == '__main__': 700 + main()
+5 -1
src/activities/reader/EpubReaderActivity.cpp
··· 672 672 void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop, 673 673 const int orientedMarginRight, const int orientedMarginBottom, 674 674 const int orientedMarginLeft) { 675 + // Force full refresh for pages with images when anti-aliasing is on, 676 + // as grayscale tones require half refresh to display correctly 677 + bool forceFullRefresh = page->hasImages() && SETTINGS.textAntiAliasing; 678 + 675 679 page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); 676 680 renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); 677 - if (pagesUntilFullRefresh <= 1) { 681 + if (forceFullRefresh || pagesUntilFullRefresh <= 1) { 678 682 renderer.displayBuffer(HalDisplay::HALF_REFRESH); 679 683 pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); 680 684 } else {
test/epubs/test_jpeg_images.epub

This is a binary file and will not be displayed.

test/epubs/test_mixed_images.epub

This is a binary file and will not be displayed.

test/epubs/test_png_images.epub

This is a binary file and will not be displayed.