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 cover image support for EPUB books (#827)

## Summary
- EPUB books with PNG cover images now display covers on the home screen
instead of blank rectangles
- Adds `PngToBmpConverter` library mirroring the existing
`JpegToBmpConverter` pattern
- Uses miniz (already in the project) for streaming zlib decompression
of PNG IDAT data
- Supports all PNG color types (Grayscale, RGB, RGBA, Palette,
Gray+Alpha)
- Optimized for ESP32-C3: batch grayscale conversion, 2KB read buffer,
same area-averaging scaling and Atkinson dithering as the JPEG path

## Changes
- **New:** `lib/PngToBmpConverter/PngToBmpConverter.h` — Public API
matching JpegToBmpConverter's interface
- **New:** `lib/PngToBmpConverter/PngToBmpConverter.cpp` — Streaming PNG
decoder + BMP converter
- **Modified:** `lib/Epub/Epub.cpp` — Added `.png` handling in
`generateCoverBmp()` and `generateThumbBmp()`

## Test plan
- [x] Tested with EPUB files using PNG covers — covers appear correctly
on home screen
- [ ] Verify with various PNG color types (most stock EPUBs use 8-bit
RGB)
- [ ] Confirm no regressions with JPEG cover EPUBs

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

**New Features**
- Added PNG format support for EPUB cover and thumbnail images. PNG
files are automatically processed and cached alongside existing
supported formats. This enhancement enables users to leverage PNG cover
artwork when generating EPUB files, improving workflow flexibility and
compatibility with common image sources.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Nik Outchcunis <outchy@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Dave Allie <dave@daveallie.com>

authored by

casualducko
Nik Outchcunis
Claude Opus 4.6
Dave Allie
and committed by
GitHub
0bc67474 00666377

+1000 -69
+70 -3
lib/Epub/Epub.cpp
··· 4 4 #include <HalStorage.h> 5 5 #include <JpegToBmpConverter.h> 6 6 #include <Logging.h> 7 + #include <PngToBmpConverter.h> 7 8 #include <ZipFile.h> 8 9 9 10 #include "Epub/parsers/ContainerParser.h" ··· 486 487 LOG_ERR("EBP", "Failed to generate BMP from cover image"); 487 488 Storage.remove(getCoverBmpPath(cropped).c_str()); 488 489 } 489 - LOG_DBG("EBP", "Generated BMP from cover image, success: %s", success ? "yes" : "no"); 490 + LOG_DBG("EBP", "Generated BMP from JPG cover image, success: %s", success ? "yes" : "no"); 490 491 return success; 491 - } else { 492 - LOG_ERR("EBP", "Cover image is not a supported format, skipping"); 493 492 } 494 493 494 + if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") { 495 + LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit"); 496 + const auto coverPngTempPath = getCachePath() + "/.cover.png"; 497 + 498 + FsFile coverPng; 499 + if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) { 500 + return false; 501 + } 502 + readItemContentsToStream(coverImageHref, coverPng, 1024); 503 + coverPng.close(); 504 + 505 + if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) { 506 + return false; 507 + } 508 + 509 + FsFile coverBmp; 510 + if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) { 511 + coverPng.close(); 512 + return false; 513 + } 514 + const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped); 515 + coverPng.close(); 516 + coverBmp.close(); 517 + Storage.remove(coverPngTempPath.c_str()); 518 + 519 + if (!success) { 520 + LOG_ERR("EBP", "Failed to generate BMP from PNG cover image"); 521 + Storage.remove(getCoverBmpPath(cropped).c_str()); 522 + } 523 + LOG_DBG("EBP", "Generated BMP from PNG cover image, success: %s", success ? "yes" : "no"); 524 + return success; 525 + } 526 + 527 + LOG_ERR("EBP", "Cover image is not a supported format, skipping"); 495 528 return false; 496 529 } 497 530 ··· 548 581 Storage.remove(getThumbBmpPath(height).c_str()); 549 582 } 550 583 LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no"); 584 + return success; 585 + } else if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") { 586 + LOG_DBG("EBP", "Generating thumb BMP from PNG cover image"); 587 + const auto coverPngTempPath = getCachePath() + "/.cover.png"; 588 + 589 + FsFile coverPng; 590 + if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) { 591 + return false; 592 + } 593 + readItemContentsToStream(coverImageHref, coverPng, 1024); 594 + coverPng.close(); 595 + 596 + if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) { 597 + return false; 598 + } 599 + 600 + FsFile thumbBmp; 601 + if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) { 602 + coverPng.close(); 603 + return false; 604 + } 605 + int THUMB_TARGET_WIDTH = height * 0.6; 606 + int THUMB_TARGET_HEIGHT = height; 607 + const bool success = 608 + PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT); 609 + coverPng.close(); 610 + thumbBmp.close(); 611 + Storage.remove(coverPngTempPath.c_str()); 612 + 613 + if (!success) { 614 + LOG_ERR("EBP", "Failed to generate thumb BMP from PNG cover image"); 615 + Storage.remove(getThumbBmpPath(height).c_str()); 616 + } 617 + LOG_DBG("EBP", "Generated thumb BMP from PNG cover image, success: %s", success ? "yes" : "no"); 551 618 return success; 552 619 } else { 553 620 LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
+17 -18
lib/Epub/Epub/blocks/ImageBlock.cpp
··· 1 1 #include "ImageBlock.h" 2 2 3 - #include <FsHelpers.h> 4 3 #include <GfxRenderer.h> 5 - #include <HardwareSerial.h> 4 + #include <Logging.h> 6 5 #include <SDCardManager.h> 7 6 #include <Serialization.h> 8 7 ··· 47 46 int widthDiff = abs(cachedWidth - expectedWidth); 48 47 int heightDiff = abs(cachedHeight - expectedHeight); 49 48 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); 49 + LOG_ERR("IMG", "Cache dimension mismatch: %dx%d vs %dx%d", cachedWidth, cachedHeight, expectedWidth, 50 + expectedHeight); 52 51 cacheFile.close(); 53 52 return false; 54 53 } ··· 57 56 expectedWidth = cachedWidth; 58 57 expectedHeight = cachedHeight; 59 58 60 - Serial.printf("[%lu] [IMG] Loading from cache: %s (%dx%d)\n", millis(), cachePath.c_str(), cachedWidth, cachedHeight); 59 + LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight); 61 60 62 61 // Read and render row by row to minimize memory usage 63 62 const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte 64 63 uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow); 65 64 if (!rowBuffer) { 66 - Serial.printf("[%lu] [IMG] Failed to allocate row buffer\n", millis()); 65 + LOG_ERR("IMG", "Failed to allocate row buffer"); 67 66 cacheFile.close(); 68 67 return false; 69 68 } 70 69 71 70 for (int row = 0; row < cachedHeight; row++) { 72 71 if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) { 73 - Serial.printf("[%lu] [IMG] Cache read error at row %d\n", millis(), row); 72 + LOG_ERR("IMG", "Cache read error at row %d", row); 74 73 free(rowBuffer); 75 74 cacheFile.close(); 76 75 return false; ··· 88 87 89 88 free(rowBuffer); 90 89 cacheFile.close(); 91 - Serial.printf("[%lu] [IMG] Cache render complete\n", millis()); 90 + LOG_DBG("IMG", "Cache render complete"); 92 91 return true; 93 92 } 94 93 95 94 } // namespace 96 95 97 96 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); 97 + LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height); 99 98 100 99 const int screenWidth = renderer.getScreenWidth(); 101 100 const int screenHeight = renderer.getScreenHeight(); 102 101 103 102 // Bounds check render position using logical screen dimensions 104 103 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); 104 + LOG_ERR("IMG", "Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)", x, y, width, height, screenWidth, 105 + screenHeight); 107 106 return; 108 107 } 109 108 ··· 117 116 // Check if image file exists 118 117 FsFile file; 119 118 if (!Storage.openFileForRead("IMG", imagePath, file)) { 120 - Serial.printf("[%lu] [IMG] Image file not found: %s\n", millis(), imagePath.c_str()); 119 + LOG_ERR("IMG", "Image file not found: %s", imagePath.c_str()); 121 120 return; 122 121 } 123 122 size_t fileSize = file.size(); 124 123 file.close(); 125 124 126 125 if (fileSize == 0) { 127 - Serial.printf("[%lu] [IMG] Image file is empty: %s\n", millis(), imagePath.c_str()); 126 + LOG_ERR("IMG", "Image file is empty: %s", imagePath.c_str()); 128 127 return; 129 128 } 130 129 131 - Serial.printf("[%lu] [IMG] Decoding and caching: %s\n", millis(), imagePath.c_str()); 130 + LOG_DBG("IMG", "Decoding and caching: %s", imagePath.c_str()); 132 131 133 132 RenderConfig config; 134 133 config.x = x; ··· 143 142 144 143 ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath); 145 144 if (!decoder) { 146 - Serial.printf("[%lu] [IMG] No decoder found for image: %s\n", millis(), imagePath.c_str()); 145 + LOG_ERR("IMG", "No decoder found for image: %s", imagePath.c_str()); 147 146 return; 148 147 } 149 148 150 - Serial.printf("[%lu] [IMG] Using %s decoder\n", millis(), decoder->getFormatName()); 149 + LOG_DBG("IMG", "Using %s decoder", decoder->getFormatName()); 151 150 152 151 bool success = decoder->decodeToFramebuffer(imagePath, renderer, config); 153 152 if (!success) { 154 - Serial.printf("[%lu] [IMG] Failed to decode image: %s\n", millis(), imagePath.c_str()); 153 + LOG_ERR("IMG", "Failed to decode image: %s", imagePath.c_str()); 155 154 return; 156 155 } 157 156 158 - Serial.printf("[%lu] [IMG] Decode successful\n", millis()); 157 + LOG_DBG("IMG", "Decode successful"); 159 158 } 160 159 161 160 bool ImageBlock::serialize(FsFile& file) {
+2 -2
lib/Epub/Epub/converters/ImageDecoderFactory.cpp
··· 1 1 #include "ImageDecoderFactory.h" 2 2 3 - #include <HardwareSerial.h> 3 + #include <Logging.h> 4 4 5 5 #include <memory> 6 6 #include <string> ··· 35 35 return pngDecoder.get(); 36 36 } 37 37 38 - Serial.printf("[%lu] [DEC] No decoder found for image: %s\n", millis(), imagePath.c_str()); 38 + LOG_ERR("DEC", "No decoder found for image: %s", imagePath.c_str()); 39 39 return nullptr; 40 40 } 41 41
+6 -7
lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp
··· 1 1 #include "ImageToFramebufferDecoder.h" 2 2 3 - #include <Arduino.h> 4 - #include <HardwareSerial.h> 3 + #include <Logging.h> 5 4 6 5 bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) { 7 6 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); 7 + LOG_ERR("IMG", "Image too large (%dx%d = %d pixels %s), max supported: %d pixels", width, height, width * height, 8 + format.c_str(), MAX_SOURCE_PIXELS); 10 9 return false; 11 10 } 12 11 return true; 13 12 } 14 13 15 14 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 - } 15 + LOG_ERR("IMG", "Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.", feature.c_str(), 16 + imagePath.c_str()); 17 + }
+13 -14
lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp
··· 1 1 #include "JpegToFramebufferConverter.h" 2 2 3 3 #include <GfxRenderer.h> 4 - #include <HardwareSerial.h> 4 + #include <Logging.h> 5 5 #include <SDCardManager.h> 6 6 #include <SdFat.h> 7 7 #include <picojpeg.h> ··· 23 23 bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) { 24 24 FsFile file; 25 25 if (!Storage.openFileForRead("JPG", imagePath, file)) { 26 - Serial.printf("[%lu] [JPG] Failed to open file for dimensions: %s\n", millis(), imagePath.c_str()); 26 + LOG_ERR("JPG", "Failed to open file for dimensions: %s", imagePath.c_str()); 27 27 return false; 28 28 } 29 29 ··· 34 34 file.close(); 35 35 36 36 if (status != 0) { 37 - Serial.printf("[%lu] [JPG] Failed to init JPEG for dimensions: %d\n", millis(), status); 37 + LOG_ERR("JPG", "Failed to init JPEG for dimensions: %d", status); 38 38 return false; 39 39 } 40 40 41 41 out.width = imageInfo.m_width; 42 42 out.height = imageInfo.m_height; 43 - Serial.printf("[%lu] [JPG] Image dimensions: %dx%d\n", millis(), out.width, out.height); 43 + LOG_DBG("JPG", "Image dimensions: %dx%d", out.width, out.height); 44 44 return true; 45 45 } 46 46 47 47 bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, 48 48 const RenderConfig& config) { 49 - Serial.printf("[%lu] [JPG] Decoding JPEG: %s\n", millis(), imagePath.c_str()); 49 + LOG_DBG("JPG", "Decoding JPEG: %s", imagePath.c_str()); 50 50 51 51 FsFile file; 52 52 if (!Storage.openFileForRead("JPG", imagePath, file)) { 53 - Serial.printf("[%lu] [JPG] Failed to open file: %s\n", millis(), imagePath.c_str()); 53 + LOG_ERR("JPG", "Failed to open file: %s", imagePath.c_str()); 54 54 return false; 55 55 } 56 56 ··· 59 59 60 60 int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0); 61 61 if (status != 0) { 62 - Serial.printf("[%lu] [JPG] picojpeg init failed: %d\n", millis(), status); 62 + LOG_ERR("JPG", "picojpeg init failed: %d", status); 63 63 file.close(); 64 64 return false; 65 65 } ··· 93 93 destHeight = (int)(imageInfo.m_height * scale); 94 94 } 95 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); 96 + LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d", imageInfo.m_width, imageInfo.m_height, 97 + destWidth, destHeight, scale, imageInfo.m_scanType, imageInfo.m_MCUWidth, imageInfo.m_MCUHeight); 99 98 100 99 if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) { 101 - Serial.printf("[%lu] [JPG] Null buffer pointers in imageInfo\n", millis()); 100 + LOG_ERR("JPG", "Null buffer pointers in imageInfo"); 102 101 file.close(); 103 102 return false; 104 103 } ··· 111 110 bool caching = !config.cachePath.empty(); 112 111 if (caching) { 113 112 if (!cache.allocate(destWidth, destHeight, config.x, config.y)) { 114 - Serial.printf("[%lu] [JPG] Failed to allocate cache buffer, continuing without caching\n", millis()); 113 + LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching"); 115 114 caching = false; 116 115 } 117 116 } ··· 125 124 break; 126 125 } 127 126 if (status != 0) { 128 - Serial.printf("[%lu] [JPG] MCU decode failed: %d\n", millis(), status); 127 + LOG_ERR("JPG", "MCU decode failed: %d", status); 129 128 file.close(); 130 129 return false; 131 130 } ··· 254 253 } 255 254 } 256 255 257 - Serial.printf("[%lu] [JPG] Decoding complete\n", millis()); 256 + LOG_DBG("JPG", "Decoding complete"); 258 257 file.close(); 259 258 260 259 // Write cache file if caching was enabled
+6 -9
lib/Epub/Epub/converters/PixelCache.h
··· 1 1 #pragma once 2 2 3 - #include <HardwareSerial.h> 4 - #include <SDCardManager.h> 5 - #include <SdFat.h> 3 + #include <HalStorage.h> 4 + #include <Logging.h> 6 5 #include <stdint.h> 7 6 8 7 #include <cstring> ··· 32 31 bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte 33 32 size_t bufferSize = (size_t)bytesPerRow * h; 34 33 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); 34 + LOG_ERR("IMG", "Cache buffer too large: %d bytes for %dx%d (limit %d)", bufferSize, w, h, MAX_CACHE_BYTES); 37 35 return false; 38 36 } 39 37 buffer = (uint8_t*)malloc(bufferSize); 40 38 if (buffer) { 41 39 memset(buffer, 0, bufferSize); 42 - Serial.printf("[%lu] [IMG] Allocated cache buffer: %d bytes for %dx%d\n", millis(), bufferSize, w, h); 40 + LOG_DBG("IMG", "Allocated cache buffer: %d bytes for %dx%d", bufferSize, w, h); 43 41 } 44 42 return buffer != nullptr; 45 43 } ··· 60 58 61 59 FsFile cacheFile; 62 60 if (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) { 63 - Serial.printf("[%lu] [IMG] Failed to open cache file for writing: %s\n", millis(), cachePath.c_str()); 61 + LOG_ERR("IMG", "Failed to open cache file for writing: %s", cachePath.c_str()); 64 62 return false; 65 63 } 66 64 ··· 71 69 cacheFile.write(buffer, bytesPerRow * height); 72 70 cacheFile.close(); 73 71 74 - Serial.printf("[%lu] [IMG] Cache written: %s (%dx%d, %d bytes)\n", millis(), cachePath.c_str(), width, height, 75 - 4 + bytesPerRow * height); 72 + LOG_DBG("IMG", "Cache written: %s (%dx%d, %d bytes)", cachePath.c_str(), width, height, 4 + bytesPerRow * height); 76 73 return true; 77 74 } 78 75
+14 -16
lib/Epub/Epub/converters/PngToFramebufferConverter.cpp
··· 1 1 #include "PngToFramebufferConverter.h" 2 2 3 3 #include <GfxRenderer.h> 4 - #include <HardwareSerial.h> 4 + #include <Logging.h> 5 5 #include <PNGdec.h> 6 6 #include <SDCardManager.h> 7 7 #include <SdFat.h> ··· 216 216 bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) { 217 217 size_t freeHeap = ESP.getFreeHeap(); 218 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); 219 + LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG); 221 220 return false; 222 221 } 223 222 224 223 PNG* png = new (std::nothrow) PNG(); 225 224 if (!png) { 226 - Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder for dimensions\n", millis()); 225 + LOG_ERR("PNG", "Failed to allocate PNG decoder for dimensions"); 227 226 return false; 228 227 } 229 228 ··· 231 230 nullptr); 232 231 233 232 if (rc != 0) { 234 - Serial.printf("[%lu] [PNG] Failed to open PNG for dimensions: %d\n", millis(), rc); 233 + LOG_ERR("PNG", "Failed to open PNG for dimensions: %d", rc); 235 234 delete png; 236 235 return false; 237 236 } ··· 246 245 247 246 bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, 248 247 const RenderConfig& config) { 249 - Serial.printf("[%lu] [PNG] Decoding PNG: %s\n", millis(), imagePath.c_str()); 248 + LOG_DBG("PNG", "Decoding PNG: %s", imagePath.c_str()); 250 249 251 250 size_t freeHeap = ESP.getFreeHeap(); 252 251 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); 252 + LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG); 255 253 return false; 256 254 } 257 255 258 256 // Heap-allocate PNG decoder (~42 KB) - freed at end of function 259 257 PNG* png = new (std::nothrow) PNG(); 260 258 if (!png) { 261 - Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder\n", millis()); 259 + LOG_ERR("PNG", "Failed to allocate PNG decoder"); 262 260 return false; 263 261 } 264 262 ··· 271 269 int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle, 272 270 pngDrawCallback); 273 271 if (rc != PNG_SUCCESS) { 274 - Serial.printf("[%lu] [PNG] Failed to open PNG: %d\n", millis(), rc); 272 + LOG_ERR("PNG", "Failed to open PNG: %d", rc); 275 273 delete png; 276 274 return false; 277 275 } ··· 303 301 } 304 302 ctx.lastDstY = -1; // Reset row tracking 305 303 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()); 304 + LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight, 305 + ctx.scale, png->getBpp()); 308 306 309 307 if (png->getBpp() != 8) { 310 308 warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath); ··· 314 312 const size_t grayBufSize = PNG_MAX_BUFFERED_PIXELS / 2; 315 313 ctx.grayLineBuffer = static_cast<uint8_t*>(malloc(grayBufSize)); 316 314 if (!ctx.grayLineBuffer) { 317 - Serial.printf("[%lu] [PNG] Failed to allocate gray line buffer\n", millis()); 315 + LOG_ERR("PNG", "Failed to allocate gray line buffer"); 318 316 png->close(); 319 317 delete png; 320 318 return false; ··· 324 322 ctx.caching = !config.cachePath.empty(); 325 323 if (ctx.caching) { 326 324 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()); 325 + LOG_ERR("PNG", "Failed to allocate cache buffer, continuing without caching"); 328 326 ctx.caching = false; 329 327 } 330 328 } ··· 337 335 ctx.grayLineBuffer = nullptr; 338 336 339 337 if (rc != PNG_SUCCESS) { 340 - Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc); 338 + LOG_ERR("PNG", "Decode failed: %d", rc); 341 339 png->close(); 342 340 delete png; 343 341 return false; ··· 345 343 346 344 png->close(); 347 345 delete png; 348 - Serial.printf("[%lu] [PNG] PNG decoding complete - render time: %lu ms\n", millis(), decodeTime); 346 + LOG_DBG("PNG", "PNG decoding complete - render time: %lu ms", decodeTime); 349 347 350 348 // Write cache file if caching was enabled and buffer was allocated 351 349 if (ctx.caching) {
+858
lib/PngToBmpConverter/PngToBmpConverter.cpp
··· 1 + #include "PngToBmpConverter.h" 2 + 3 + #include <HalStorage.h> 4 + #include <Logging.h> 5 + #include <miniz.h> 6 + 7 + #include <cstdio> 8 + #include <cstring> 9 + 10 + #include "BitmapHelpers.h" 11 + 12 + // ============================================================================ 13 + // IMAGE PROCESSING OPTIONS - Same as JpegToBmpConverter for consistency 14 + // ============================================================================ 15 + constexpr bool USE_8BIT_OUTPUT = false; 16 + constexpr bool USE_ATKINSON = true; 17 + constexpr bool USE_FLOYD_STEINBERG = false; 18 + constexpr bool USE_PRESCALE = true; 19 + constexpr int TARGET_MAX_WIDTH = 480; 20 + constexpr int TARGET_MAX_HEIGHT = 800; 21 + // ============================================================================ 22 + 23 + // PNG constants 24 + static constexpr uint8_t PNG_SIGNATURE[8] = {137, 80, 78, 71, 13, 10, 26, 10}; 25 + 26 + // PNG color types 27 + enum PngColorType : uint8_t { 28 + PNG_COLOR_GRAYSCALE = 0, 29 + PNG_COLOR_RGB = 2, 30 + PNG_COLOR_PALETTE = 3, 31 + PNG_COLOR_GRAYSCALE_ALPHA = 4, 32 + PNG_COLOR_RGBA = 6, 33 + }; 34 + 35 + // PNG filter types 36 + enum PngFilter : uint8_t { 37 + PNG_FILTER_NONE = 0, 38 + PNG_FILTER_SUB = 1, 39 + PNG_FILTER_UP = 2, 40 + PNG_FILTER_AVERAGE = 3, 41 + PNG_FILTER_PAETH = 4, 42 + }; 43 + 44 + // Read a big-endian 32-bit value from file 45 + static bool readBE32(FsFile& file, uint32_t& value) { 46 + uint8_t buf[4]; 47 + if (file.read(buf, 4) != 4) return false; 48 + value = (static_cast<uint32_t>(buf[0]) << 24) | (static_cast<uint32_t>(buf[1]) << 16) | 49 + (static_cast<uint32_t>(buf[2]) << 8) | buf[3]; 50 + return true; 51 + } 52 + 53 + // BMP writing helpers (same as JpegToBmpConverter) 54 + inline void write16(Print& out, const uint16_t value) { 55 + out.write(value & 0xFF); 56 + out.write((value >> 8) & 0xFF); 57 + } 58 + 59 + inline void write32(Print& out, const uint32_t value) { 60 + out.write(value & 0xFF); 61 + out.write((value >> 8) & 0xFF); 62 + out.write((value >> 16) & 0xFF); 63 + out.write((value >> 24) & 0xFF); 64 + } 65 + 66 + inline void write32Signed(Print& out, const int32_t value) { 67 + out.write(value & 0xFF); 68 + out.write((value >> 8) & 0xFF); 69 + out.write((value >> 16) & 0xFF); 70 + out.write((value >> 24) & 0xFF); 71 + } 72 + 73 + static void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { 74 + const int bytesPerRow = (width + 3) / 4 * 4; 75 + const int imageSize = bytesPerRow * height; 76 + const uint32_t paletteSize = 256 * 4; 77 + const uint32_t fileSize = 14 + 40 + paletteSize + imageSize; 78 + 79 + bmpOut.write('B'); 80 + bmpOut.write('M'); 81 + write32(bmpOut, fileSize); 82 + write32(bmpOut, 0); 83 + write32(bmpOut, 14 + 40 + paletteSize); 84 + 85 + write32(bmpOut, 40); 86 + write32Signed(bmpOut, width); 87 + write32Signed(bmpOut, -height); 88 + write16(bmpOut, 1); 89 + write16(bmpOut, 8); 90 + write32(bmpOut, 0); 91 + write32(bmpOut, imageSize); 92 + write32(bmpOut, 2835); 93 + write32(bmpOut, 2835); 94 + write32(bmpOut, 256); 95 + write32(bmpOut, 256); 96 + 97 + for (int i = 0; i < 256; i++) { 98 + bmpOut.write(static_cast<uint8_t>(i)); 99 + bmpOut.write(static_cast<uint8_t>(i)); 100 + bmpOut.write(static_cast<uint8_t>(i)); 101 + bmpOut.write(static_cast<uint8_t>(0)); 102 + } 103 + } 104 + 105 + static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) { 106 + const int bytesPerRow = (width + 31) / 32 * 4; 107 + const int imageSize = bytesPerRow * height; 108 + const uint32_t fileSize = 62 + imageSize; 109 + 110 + bmpOut.write('B'); 111 + bmpOut.write('M'); 112 + write32(bmpOut, fileSize); 113 + write32(bmpOut, 0); 114 + write32(bmpOut, 62); 115 + 116 + write32(bmpOut, 40); 117 + write32Signed(bmpOut, width); 118 + write32Signed(bmpOut, -height); 119 + write16(bmpOut, 1); 120 + write16(bmpOut, 1); 121 + write32(bmpOut, 0); 122 + write32(bmpOut, imageSize); 123 + write32(bmpOut, 2835); 124 + write32(bmpOut, 2835); 125 + write32(bmpOut, 2); 126 + write32(bmpOut, 2); 127 + 128 + uint8_t palette[8] = {0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00}; 129 + for (const uint8_t i : palette) { 130 + bmpOut.write(i); 131 + } 132 + } 133 + 134 + static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) { 135 + const int bytesPerRow = (width * 2 + 31) / 32 * 4; 136 + const int imageSize = bytesPerRow * height; 137 + const uint32_t fileSize = 70 + imageSize; 138 + 139 + bmpOut.write('B'); 140 + bmpOut.write('M'); 141 + write32(bmpOut, fileSize); 142 + write32(bmpOut, 0); 143 + write32(bmpOut, 70); 144 + 145 + write32(bmpOut, 40); 146 + write32Signed(bmpOut, width); 147 + write32Signed(bmpOut, -height); 148 + write16(bmpOut, 1); 149 + write16(bmpOut, 2); 150 + write32(bmpOut, 0); 151 + write32(bmpOut, imageSize); 152 + write32(bmpOut, 2835); 153 + write32(bmpOut, 2835); 154 + write32(bmpOut, 4); 155 + write32(bmpOut, 4); 156 + 157 + uint8_t palette[16] = {0x00, 0x00, 0x00, 0x00, 0x55, 0x55, 0x55, 0x00, 158 + 0xAA, 0xAA, 0xAA, 0x00, 0xFF, 0xFF, 0xFF, 0x00}; 159 + for (const uint8_t i : palette) { 160 + bmpOut.write(i); 161 + } 162 + } 163 + 164 + // Paeth predictor function per PNG spec 165 + static inline uint8_t paethPredictor(uint8_t a, uint8_t b, uint8_t c) { 166 + int p = static_cast<int>(a) + b - c; 167 + int pa = p > a ? p - a : a - p; 168 + int pb = p > b ? p - b : b - p; 169 + int pc = p > c ? p - c : c - p; 170 + if (pa <= pb && pa <= pc) return a; 171 + if (pb <= pc) return b; 172 + return c; 173 + } 174 + 175 + // Context for streaming PNG decompression 176 + struct PngDecodeContext { 177 + FsFile& file; 178 + 179 + // PNG image properties 180 + uint32_t width; 181 + uint32_t height; 182 + uint8_t bitDepth; 183 + uint8_t colorType; 184 + uint8_t bytesPerPixel; // after expanding sub-byte depths 185 + uint32_t rawRowBytes; // bytes per raw row (without filter byte) 186 + 187 + // Scanline buffers 188 + uint8_t* currentRow; // current defiltered scanline 189 + uint8_t* previousRow; // previous defiltered scanline 190 + 191 + // zlib decompression state 192 + mz_stream zstream; 193 + bool zstreamInitialized; 194 + 195 + // Chunk reading state 196 + uint32_t chunkBytesRemaining; // bytes left in current IDAT chunk 197 + bool idatFinished; // no more IDAT chunks 198 + 199 + // File read buffer for feeding zlib 200 + uint8_t readBuf[2048]; 201 + 202 + // Palette for indexed color (type 3) 203 + uint8_t palette[256 * 3]; 204 + int paletteSize; 205 + }; 206 + 207 + // Read the next IDAT chunk header, skipping non-IDAT chunks 208 + // Returns true if an IDAT chunk was found 209 + static bool findNextIdatChunk(PngDecodeContext& ctx) { 210 + while (true) { 211 + uint32_t chunkLen; 212 + if (!readBE32(ctx.file, chunkLen)) return false; 213 + 214 + uint8_t chunkType[4]; 215 + if (ctx.file.read(chunkType, 4) != 4) return false; 216 + 217 + if (memcmp(chunkType, "IDAT", 4) == 0) { 218 + ctx.chunkBytesRemaining = chunkLen; 219 + return true; 220 + } 221 + 222 + // Skip this chunk's data + 4-byte CRC 223 + // Use seek to skip efficiently 224 + if (!ctx.file.seekCur(chunkLen + 4)) return false; 225 + 226 + // If we hit IEND, there are no more chunks 227 + if (memcmp(chunkType, "IEND", 4) == 0) { 228 + return false; 229 + } 230 + } 231 + } 232 + 233 + // Feed compressed data to zlib from IDAT chunks 234 + // Returns number of bytes made available in zstream, or -1 on error 235 + static int feedZlibInput(PngDecodeContext& ctx) { 236 + if (ctx.idatFinished) return 0; 237 + 238 + // If current IDAT chunk is exhausted, skip its CRC and find next 239 + while (ctx.chunkBytesRemaining == 0) { 240 + // Skip 4-byte CRC of previous IDAT 241 + if (!ctx.file.seekCur(4)) return -1; 242 + 243 + if (!findNextIdatChunk(ctx)) { 244 + ctx.idatFinished = true; 245 + return 0; 246 + } 247 + } 248 + 249 + // Read from current IDAT chunk 250 + size_t toRead = sizeof(ctx.readBuf); 251 + if (toRead > ctx.chunkBytesRemaining) toRead = ctx.chunkBytesRemaining; 252 + 253 + int bytesRead = ctx.file.read(ctx.readBuf, toRead); 254 + if (bytesRead <= 0) return -1; 255 + 256 + ctx.chunkBytesRemaining -= bytesRead; 257 + ctx.zstream.next_in = ctx.readBuf; 258 + ctx.zstream.avail_in = bytesRead; 259 + 260 + return bytesRead; 261 + } 262 + 263 + // Decompress exactly 'needed' bytes into 'dest' 264 + static bool decompressBytes(PngDecodeContext& ctx, uint8_t* dest, size_t needed) { 265 + ctx.zstream.next_out = dest; 266 + ctx.zstream.avail_out = needed; 267 + 268 + while (ctx.zstream.avail_out > 0) { 269 + if (ctx.zstream.avail_in == 0) { 270 + int fed = feedZlibInput(ctx); 271 + if (fed < 0) return false; 272 + if (fed == 0) { 273 + // Try one more inflate to flush 274 + int ret = mz_inflate(&ctx.zstream, MZ_SYNC_FLUSH); 275 + if (ctx.zstream.avail_out == 0) break; 276 + return false; 277 + } 278 + } 279 + 280 + int ret = mz_inflate(&ctx.zstream, MZ_SYNC_FLUSH); 281 + if (ret != MZ_OK && ret != MZ_STREAM_END && ret != MZ_BUF_ERROR) { 282 + LOG_ERR("PNG", "zlib inflate error: %d", ret); 283 + return false; 284 + } 285 + if (ret == MZ_STREAM_END) break; 286 + } 287 + 288 + return ctx.zstream.avail_out == 0; 289 + } 290 + 291 + // Decode one scanline: decompress filter byte + raw bytes, then unfilter 292 + static bool decodeScanline(PngDecodeContext& ctx) { 293 + // Decompress filter byte 294 + uint8_t filterType; 295 + if (!decompressBytes(ctx, &filterType, 1)) return false; 296 + 297 + // Decompress raw row data into currentRow 298 + if (!decompressBytes(ctx, ctx.currentRow, ctx.rawRowBytes)) return false; 299 + 300 + // Apply reverse filter 301 + const int bpp = ctx.bytesPerPixel; 302 + 303 + switch (filterType) { 304 + case PNG_FILTER_NONE: 305 + break; 306 + 307 + case PNG_FILTER_SUB: 308 + for (uint32_t i = bpp; i < ctx.rawRowBytes; i++) { 309 + ctx.currentRow[i] += ctx.currentRow[i - bpp]; 310 + } 311 + break; 312 + 313 + case PNG_FILTER_UP: 314 + for (uint32_t i = 0; i < ctx.rawRowBytes; i++) { 315 + ctx.currentRow[i] += ctx.previousRow[i]; 316 + } 317 + break; 318 + 319 + case PNG_FILTER_AVERAGE: 320 + for (uint32_t i = 0; i < ctx.rawRowBytes; i++) { 321 + uint8_t a = (i >= static_cast<uint32_t>(bpp)) ? ctx.currentRow[i - bpp] : 0; 322 + uint8_t b = ctx.previousRow[i]; 323 + ctx.currentRow[i] += (a + b) / 2; 324 + } 325 + break; 326 + 327 + case PNG_FILTER_PAETH: 328 + for (uint32_t i = 0; i < ctx.rawRowBytes; i++) { 329 + uint8_t a = (i >= static_cast<uint32_t>(bpp)) ? ctx.currentRow[i - bpp] : 0; 330 + uint8_t b = ctx.previousRow[i]; 331 + uint8_t c = (i >= static_cast<uint32_t>(bpp)) ? ctx.previousRow[i - bpp] : 0; 332 + ctx.currentRow[i] += paethPredictor(a, b, c); 333 + } 334 + break; 335 + 336 + default: 337 + LOG_ERR("PNG", "Unknown filter type: %d", filterType); 338 + return false; 339 + } 340 + 341 + return true; 342 + } 343 + 344 + // Batch-convert an entire scanline to grayscale. 345 + // Branches once on colorType/bitDepth, then runs a tight loop for the whole row. 346 + static void convertScanlineToGray(const PngDecodeContext& ctx, uint8_t* grayRow) { 347 + const uint8_t* src = ctx.currentRow; 348 + const uint32_t w = ctx.width; 349 + 350 + switch (ctx.colorType) { 351 + case PNG_COLOR_GRAYSCALE: 352 + if (ctx.bitDepth == 8) { 353 + memcpy(grayRow, src, w); 354 + } else if (ctx.bitDepth == 16) { 355 + for (uint32_t x = 0; x < w; x++) grayRow[x] = src[x * 2]; 356 + } else { 357 + const int ppb = 8 / ctx.bitDepth; 358 + const uint8_t mask = (1 << ctx.bitDepth) - 1; 359 + for (uint32_t x = 0; x < w; x++) { 360 + int shift = (ppb - 1 - (x % ppb)) * ctx.bitDepth; 361 + grayRow[x] = (src[x / ppb] >> shift & mask) * 255 / mask; 362 + } 363 + } 364 + break; 365 + 366 + case PNG_COLOR_RGB: 367 + if (ctx.bitDepth == 8) { 368 + // Fast path: most common EPUB cover format 369 + for (uint32_t x = 0; x < w; x++) { 370 + const uint8_t* p = src + x * 3; 371 + grayRow[x] = (p[0] * 25 + p[1] * 50 + p[2] * 25) / 100; 372 + } 373 + } else { 374 + for (uint32_t x = 0; x < w; x++) { 375 + grayRow[x] = (src[x * 6] * 25 + src[x * 6 + 2] * 50 + src[x * 6 + 4] * 25) / 100; 376 + } 377 + } 378 + break; 379 + 380 + case PNG_COLOR_PALETTE: { 381 + const int ppb = 8 / ctx.bitDepth; 382 + const uint8_t mask = (1 << ctx.bitDepth) - 1; 383 + const uint8_t* pal = ctx.palette; 384 + const int palSize = ctx.paletteSize; 385 + for (uint32_t x = 0; x < w; x++) { 386 + int shift = (ppb - 1 - (x % ppb)) * ctx.bitDepth; 387 + uint8_t idx = (src[x / ppb] >> shift) & mask; 388 + if (idx >= palSize) idx = 0; 389 + grayRow[x] = (pal[idx * 3] * 25 + pal[idx * 3 + 1] * 50 + pal[idx * 3 + 2] * 25) / 100; 390 + } 391 + break; 392 + } 393 + 394 + case PNG_COLOR_GRAYSCALE_ALPHA: 395 + if (ctx.bitDepth == 8) { 396 + for (uint32_t x = 0; x < w; x++) grayRow[x] = src[x * 2]; 397 + } else { 398 + for (uint32_t x = 0; x < w; x++) grayRow[x] = src[x * 4]; 399 + } 400 + break; 401 + 402 + case PNG_COLOR_RGBA: 403 + if (ctx.bitDepth == 8) { 404 + for (uint32_t x = 0; x < w; x++) { 405 + const uint8_t* p = src + x * 4; 406 + grayRow[x] = (p[0] * 25 + p[1] * 50 + p[2] * 25) / 100; 407 + } 408 + } else { 409 + for (uint32_t x = 0; x < w; x++) { 410 + grayRow[x] = (src[x * 8] * 25 + src[x * 8 + 2] * 50 + src[x * 8 + 4] * 25) / 100; 411 + } 412 + } 413 + break; 414 + 415 + default: 416 + memset(grayRow, 128, w); 417 + break; 418 + } 419 + } 420 + 421 + bool PngToBmpConverter::pngFileToBmpStreamInternal(FsFile& pngFile, Print& bmpOut, int targetWidth, int targetHeight, 422 + bool oneBit, bool crop) { 423 + LOG_DBG("PNG", "Converting PNG to %s BMP (target: %dx%d)", oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight); 424 + 425 + // Verify PNG signature 426 + uint8_t sig[8]; 427 + if (pngFile.read(sig, 8) != 8 || memcmp(sig, PNG_SIGNATURE, 8) != 0) { 428 + LOG_ERR("PNG", "Invalid PNG signature"); 429 + return false; 430 + } 431 + 432 + // Read IHDR chunk 433 + uint32_t ihdrLen; 434 + if (!readBE32(pngFile, ihdrLen)) return false; 435 + 436 + uint8_t ihdrType[4]; 437 + if (pngFile.read(ihdrType, 4) != 4 || memcmp(ihdrType, "IHDR", 4) != 0) { 438 + LOG_ERR("PNG", "Missing IHDR chunk"); 439 + return false; 440 + } 441 + 442 + uint32_t width, height; 443 + if (!readBE32(pngFile, width) || !readBE32(pngFile, height)) return false; 444 + 445 + uint8_t ihdrRest[5]; 446 + if (pngFile.read(ihdrRest, 5) != 5) return false; 447 + 448 + uint8_t bitDepth = ihdrRest[0]; 449 + uint8_t colorType = ihdrRest[1]; 450 + uint8_t compression = ihdrRest[2]; 451 + uint8_t filter = ihdrRest[3]; 452 + uint8_t interlace = ihdrRest[4]; 453 + 454 + // Skip IHDR CRC 455 + pngFile.seekCur(4); 456 + 457 + LOG_DBG("PNG", "Image: %ux%u, depth=%u, color=%u, interlace=%u", width, height, bitDepth, colorType, interlace); 458 + 459 + if (compression != 0 || filter != 0) { 460 + LOG_ERR("PNG", "Unsupported compression/filter method"); 461 + return false; 462 + } 463 + 464 + if (interlace != 0) { 465 + LOG_ERR("PNG", "Interlaced PNGs not supported"); 466 + return false; 467 + } 468 + 469 + // Safety limits 470 + constexpr int MAX_IMAGE_WIDTH = 2048; 471 + constexpr int MAX_IMAGE_HEIGHT = 3072; 472 + 473 + if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT || width == 0 || height == 0) { 474 + LOG_ERR("PNG", "Image too large or zero (%ux%u)", width, height); 475 + return false; 476 + } 477 + 478 + // Calculate bytes per pixel and raw row bytes 479 + uint8_t bytesPerPixel; 480 + uint32_t rawRowBytes; 481 + 482 + switch (colorType) { 483 + case PNG_COLOR_GRAYSCALE: 484 + if (bitDepth == 16) { 485 + bytesPerPixel = 2; 486 + rawRowBytes = width * 2; 487 + } else if (bitDepth == 8) { 488 + bytesPerPixel = 1; 489 + rawRowBytes = width; 490 + } else { 491 + // Sub-byte: 1, 2, or 4 bits 492 + bytesPerPixel = 1; 493 + rawRowBytes = (width * bitDepth + 7) / 8; 494 + } 495 + break; 496 + case PNG_COLOR_RGB: 497 + bytesPerPixel = (bitDepth == 16) ? 6 : 3; 498 + rawRowBytes = width * bytesPerPixel; 499 + break; 500 + case PNG_COLOR_PALETTE: 501 + bytesPerPixel = 1; 502 + rawRowBytes = (width * bitDepth + 7) / 8; 503 + break; 504 + case PNG_COLOR_GRAYSCALE_ALPHA: 505 + bytesPerPixel = (bitDepth == 16) ? 4 : 2; 506 + rawRowBytes = width * bytesPerPixel; 507 + break; 508 + case PNG_COLOR_RGBA: 509 + bytesPerPixel = (bitDepth == 16) ? 8 : 4; 510 + rawRowBytes = width * bytesPerPixel; 511 + break; 512 + default: 513 + LOG_ERR("PNG", "Unsupported color type: %d", colorType); 514 + return false; 515 + } 516 + 517 + // Validate raw row bytes won't cause memory issues 518 + if (rawRowBytes > 16384) { 519 + LOG_ERR("PNG", "Row too large: %u bytes", rawRowBytes); 520 + return false; 521 + } 522 + 523 + // Initialize decode context 524 + PngDecodeContext ctx = {.file = pngFile, 525 + .width = width, 526 + .height = height, 527 + .bitDepth = bitDepth, 528 + .colorType = colorType, 529 + .bytesPerPixel = bytesPerPixel, 530 + .rawRowBytes = rawRowBytes, 531 + .currentRow = nullptr, 532 + .previousRow = nullptr, 533 + .zstream = {}, 534 + .zstreamInitialized = false, 535 + .chunkBytesRemaining = 0, 536 + .idatFinished = false, 537 + .readBuf = {}, 538 + .palette = {}, 539 + .paletteSize = 0}; 540 + 541 + // Allocate scanline buffers 542 + ctx.currentRow = static_cast<uint8_t*>(malloc(rawRowBytes)); 543 + ctx.previousRow = static_cast<uint8_t*>(calloc(rawRowBytes, 1)); 544 + if (!ctx.currentRow || !ctx.previousRow) { 545 + LOG_ERR("PNG", "Failed to allocate scanline buffers (%u bytes each)", rawRowBytes); 546 + free(ctx.currentRow); 547 + free(ctx.previousRow); 548 + return false; 549 + } 550 + 551 + // Scan for PLTE chunk (palette) and first IDAT chunk 552 + // We need to read chunks until we find IDAT, collecting PLTE along the way 553 + bool foundIdat = false; 554 + while (!foundIdat) { 555 + uint32_t chunkLen; 556 + if (!readBE32(pngFile, chunkLen)) break; 557 + 558 + uint8_t chunkType[4]; 559 + if (pngFile.read(chunkType, 4) != 4) break; 560 + 561 + if (memcmp(chunkType, "PLTE", 4) == 0) { 562 + int entries = chunkLen / 3; 563 + if (entries > 256) entries = 256; 564 + ctx.paletteSize = entries; 565 + size_t palBytes = entries * 3; 566 + pngFile.read(ctx.palette, palBytes); 567 + // Skip any remaining palette data 568 + if (chunkLen > palBytes) pngFile.seekCur(chunkLen - palBytes); 569 + pngFile.seekCur(4); // CRC 570 + } else if (memcmp(chunkType, "IDAT", 4) == 0) { 571 + ctx.chunkBytesRemaining = chunkLen; 572 + foundIdat = true; 573 + } else if (memcmp(chunkType, "IEND", 4) == 0) { 574 + break; 575 + } else { 576 + // Skip unknown chunk 577 + pngFile.seekCur(chunkLen + 4); 578 + } 579 + } 580 + 581 + if (!foundIdat) { 582 + LOG_ERR("PNG", "No IDAT chunk found"); 583 + free(ctx.currentRow); 584 + free(ctx.previousRow); 585 + return false; 586 + } 587 + 588 + // Initialize zlib decompression 589 + memset(&ctx.zstream, 0, sizeof(ctx.zstream)); 590 + if (mz_inflateInit(&ctx.zstream) != MZ_OK) { 591 + LOG_ERR("PNG", "Failed to initialize zlib"); 592 + free(ctx.currentRow); 593 + free(ctx.previousRow); 594 + return false; 595 + } 596 + ctx.zstreamInitialized = true; 597 + 598 + // Calculate output dimensions (same logic as JpegToBmpConverter) 599 + int outWidth = width; 600 + int outHeight = height; 601 + uint32_t scaleX_fp = 65536; 602 + uint32_t scaleY_fp = 65536; 603 + bool needsScaling = false; 604 + 605 + if (targetWidth > 0 && targetHeight > 0 && 606 + (static_cast<int>(width) > targetWidth || static_cast<int>(height) > targetHeight)) { 607 + const float scaleToFitWidth = static_cast<float>(targetWidth) / width; 608 + const float scaleToFitHeight = static_cast<float>(targetHeight) / height; 609 + float scale = 1.0; 610 + if (crop) { 611 + scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; 612 + } else { 613 + scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; 614 + } 615 + 616 + outWidth = static_cast<int>(width * scale); 617 + outHeight = static_cast<int>(height * scale); 618 + if (outWidth < 1) outWidth = 1; 619 + if (outHeight < 1) outHeight = 1; 620 + 621 + scaleX_fp = (static_cast<uint32_t>(width) << 16) / outWidth; 622 + scaleY_fp = (static_cast<uint32_t>(height) << 16) / outHeight; 623 + needsScaling = true; 624 + 625 + LOG_DBG("PNG", "Pre-scaling %ux%u -> %dx%d (fit to %dx%d)", width, height, outWidth, outHeight, targetWidth, 626 + targetHeight); 627 + } 628 + 629 + // Write BMP header 630 + int bytesPerRow; 631 + if (USE_8BIT_OUTPUT && !oneBit) { 632 + writeBmpHeader8bit(bmpOut, outWidth, outHeight); 633 + bytesPerRow = (outWidth + 3) / 4 * 4; 634 + } else if (oneBit) { 635 + writeBmpHeader1bit(bmpOut, outWidth, outHeight); 636 + bytesPerRow = (outWidth + 31) / 32 * 4; 637 + } else { 638 + writeBmpHeader2bit(bmpOut, outWidth, outHeight); 639 + bytesPerRow = (outWidth * 2 + 31) / 32 * 4; 640 + } 641 + 642 + // Allocate BMP row buffer 643 + auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow)); 644 + if (!rowBuffer) { 645 + LOG_ERR("PNG", "Failed to allocate row buffer"); 646 + mz_inflateEnd(&ctx.zstream); 647 + free(ctx.currentRow); 648 + free(ctx.previousRow); 649 + return false; 650 + } 651 + 652 + // Create ditherers (same as JpegToBmpConverter) 653 + AtkinsonDitherer* atkinsonDitherer = nullptr; 654 + FloydSteinbergDitherer* fsDitherer = nullptr; 655 + Atkinson1BitDitherer* atkinson1BitDitherer = nullptr; 656 + 657 + if (oneBit) { 658 + atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth); 659 + } else if (!USE_8BIT_OUTPUT) { 660 + if (USE_ATKINSON) { 661 + atkinsonDitherer = new AtkinsonDitherer(outWidth); 662 + } else if (USE_FLOYD_STEINBERG) { 663 + fsDitherer = new FloydSteinbergDitherer(outWidth); 664 + } 665 + } 666 + 667 + // Scaling accumulators 668 + uint32_t* rowAccum = nullptr; 669 + uint16_t* rowCount = nullptr; 670 + int currentOutY = 0; 671 + uint32_t nextOutY_srcStart = 0; 672 + 673 + if (needsScaling) { 674 + rowAccum = new uint32_t[outWidth](); 675 + rowCount = new uint16_t[outWidth](); 676 + nextOutY_srcStart = scaleY_fp; 677 + } 678 + 679 + // Allocate grayscale row buffer - batch-convert each scanline to avoid 680 + // per-pixel getPixelGray() switch overhead in the hot loops 681 + auto* grayRow = static_cast<uint8_t*>(malloc(width)); 682 + if (!grayRow) { 683 + LOG_ERR("PNG", "Failed to allocate grayscale row buffer"); 684 + delete[] rowAccum; 685 + delete[] rowCount; 686 + delete atkinsonDitherer; 687 + delete fsDitherer; 688 + delete atkinson1BitDitherer; 689 + free(rowBuffer); 690 + mz_inflateEnd(&ctx.zstream); 691 + free(ctx.currentRow); 692 + free(ctx.previousRow); 693 + return false; 694 + } 695 + 696 + bool success = true; 697 + 698 + // Process each scanline 699 + for (uint32_t y = 0; y < height; y++) { 700 + // Decode one scanline 701 + if (!decodeScanline(ctx)) { 702 + LOG_ERR("PNG", "Failed to decode scanline %u", y); 703 + success = false; 704 + break; 705 + } 706 + 707 + // Batch-convert entire scanline to grayscale (one branch, tight loop) 708 + convertScanlineToGray(ctx, grayRow); 709 + 710 + if (!needsScaling) { 711 + // Direct output (no scaling) 712 + memset(rowBuffer, 0, bytesPerRow); 713 + 714 + if (USE_8BIT_OUTPUT && !oneBit) { 715 + for (int x = 0; x < outWidth; x++) { 716 + rowBuffer[x] = adjustPixel(grayRow[x]); 717 + } 718 + } else if (oneBit) { 719 + for (int x = 0; x < outWidth; x++) { 720 + const uint8_t bit = 721 + atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(grayRow[x], x) : quantize1bit(grayRow[x], x, y); 722 + const int byteIndex = x / 8; 723 + const int bitOffset = 7 - (x % 8); 724 + rowBuffer[byteIndex] |= (bit << bitOffset); 725 + } 726 + if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); 727 + } else { 728 + for (int x = 0; x < outWidth; x++) { 729 + const uint8_t gray = adjustPixel(grayRow[x]); 730 + uint8_t twoBit; 731 + if (atkinsonDitherer) { 732 + twoBit = atkinsonDitherer->processPixel(gray, x); 733 + } else if (fsDitherer) { 734 + twoBit = fsDitherer->processPixel(gray, x); 735 + } else { 736 + twoBit = quantize(gray, x, y); 737 + } 738 + const int byteIndex = (x * 2) / 8; 739 + const int bitOffset = 6 - ((x * 2) % 8); 740 + rowBuffer[byteIndex] |= (twoBit << bitOffset); 741 + } 742 + if (atkinsonDitherer) 743 + atkinsonDitherer->nextRow(); 744 + else if (fsDitherer) 745 + fsDitherer->nextRow(); 746 + } 747 + bmpOut.write(rowBuffer, bytesPerRow); 748 + } else { 749 + // Area-averaging scaling (same as JpegToBmpConverter) 750 + for (int outX = 0; outX < outWidth; outX++) { 751 + const int srcXStart = (static_cast<uint32_t>(outX) * scaleX_fp) >> 16; 752 + const int srcXEnd = (static_cast<uint32_t>(outX + 1) * scaleX_fp) >> 16; 753 + 754 + int sum = 0; 755 + int count = 0; 756 + for (int srcX = srcXStart; srcX < srcXEnd && srcX < static_cast<int>(width); srcX++) { 757 + sum += grayRow[srcX]; 758 + count++; 759 + } 760 + 761 + if (count == 0 && srcXStart < static_cast<int>(width)) { 762 + sum = grayRow[srcXStart]; 763 + count = 1; 764 + } 765 + 766 + rowAccum[outX] += sum; 767 + rowCount[outX] += count; 768 + } 769 + 770 + // Check if we've crossed into the next output row 771 + const uint32_t srcY_fp = static_cast<uint32_t>(y + 1) << 16; 772 + 773 + if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) { 774 + memset(rowBuffer, 0, bytesPerRow); 775 + 776 + if (USE_8BIT_OUTPUT && !oneBit) { 777 + for (int x = 0; x < outWidth; x++) { 778 + const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; 779 + rowBuffer[x] = adjustPixel(gray); 780 + } 781 + } else if (oneBit) { 782 + for (int x = 0; x < outWidth; x++) { 783 + const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; 784 + const uint8_t bit = 785 + atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, currentOutY); 786 + const int byteIndex = x / 8; 787 + const int bitOffset = 7 - (x % 8); 788 + rowBuffer[byteIndex] |= (bit << bitOffset); 789 + } 790 + if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); 791 + } else { 792 + for (int x = 0; x < outWidth; x++) { 793 + const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0); 794 + uint8_t twoBit; 795 + if (atkinsonDitherer) { 796 + twoBit = atkinsonDitherer->processPixel(gray, x); 797 + } else if (fsDitherer) { 798 + twoBit = fsDitherer->processPixel(gray, x); 799 + } else { 800 + twoBit = quantize(gray, x, currentOutY); 801 + } 802 + const int byteIndex = (x * 2) / 8; 803 + const int bitOffset = 6 - ((x * 2) % 8); 804 + rowBuffer[byteIndex] |= (twoBit << bitOffset); 805 + } 806 + if (atkinsonDitherer) 807 + atkinsonDitherer->nextRow(); 808 + else if (fsDitherer) 809 + fsDitherer->nextRow(); 810 + } 811 + 812 + bmpOut.write(rowBuffer, bytesPerRow); 813 + currentOutY++; 814 + 815 + memset(rowAccum, 0, outWidth * sizeof(uint32_t)); 816 + memset(rowCount, 0, outWidth * sizeof(uint16_t)); 817 + 818 + nextOutY_srcStart = static_cast<uint32_t>(currentOutY + 1) * scaleY_fp; 819 + } 820 + } 821 + 822 + // Swap current/previous row buffers 823 + uint8_t* temp = ctx.previousRow; 824 + ctx.previousRow = ctx.currentRow; 825 + ctx.currentRow = temp; 826 + } 827 + 828 + // Clean up 829 + free(grayRow); 830 + delete[] rowAccum; 831 + delete[] rowCount; 832 + delete atkinsonDitherer; 833 + delete fsDitherer; 834 + delete atkinson1BitDitherer; 835 + free(rowBuffer); 836 + mz_inflateEnd(&ctx.zstream); 837 + free(ctx.currentRow); 838 + free(ctx.previousRow); 839 + 840 + if (success) { 841 + LOG_DBG("PNG", "Successfully converted PNG to BMP"); 842 + } 843 + return success; 844 + } 845 + 846 + bool PngToBmpConverter::pngFileToBmpStream(FsFile& pngFile, Print& bmpOut, bool crop) { 847 + return pngFileToBmpStreamInternal(pngFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop); 848 + } 849 + 850 + bool PngToBmpConverter::pngFileToBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth, 851 + int targetMaxHeight) { 852 + return pngFileToBmpStreamInternal(pngFile, bmpOut, targetMaxWidth, targetMaxHeight, false); 853 + } 854 + 855 + bool PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth, 856 + int targetMaxHeight) { 857 + return pngFileToBmpStreamInternal(pngFile, bmpOut, targetMaxWidth, targetMaxHeight, true, true); 858 + }
+14
lib/PngToBmpConverter/PngToBmpConverter.h
··· 1 + #pragma once 2 + 3 + class FsFile; 4 + class Print; 5 + 6 + class PngToBmpConverter { 7 + static bool pngFileToBmpStreamInternal(FsFile& pngFile, Print& bmpOut, int targetWidth, int targetHeight, bool oneBit, 8 + bool crop = true); 9 + 10 + public: 11 + static bool pngFileToBmpStream(FsFile& pngFile, Print& bmpOut, bool crop = true); 12 + static bool pngFileToBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); 13 + static bool pngFileTo1BitBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); 14 + };