feat: Add PNG cover image support for EPUB books (#827)

Cherry-pick upstream PR #827 with conflict resolution for mod/master:

- Add PngToBmpConverter library for PNG cover → BMP conversion
- Add PNG thumbnail generation in generateThumbBmp()
- Fix generateCoverBmp() PNG block to use effectiveCoverImageHref
  (consistent with mod's fallback cover candidate probing)
- Add .png to getCoverCandidates() extensions
- Use LOG_ERR macro in ImageToFramebufferDecoder (mod standard)
- Upstream image converter refinements (ImageBlock, PixelCache,
  JpegToFramebufferConverter, PngToFramebufferConverter)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-02-16 17:04:33 -05:00
parent 424e332c75
commit 61fb11cae3
9 changed files with 1006 additions and 71 deletions

View File

@@ -5,6 +5,7 @@
#include <HalStorage.h>
#include <JpegToBmpConverter.h>
#include <Logging.h>
#include <PngToBmpConverter.h>
#include <ZipFile.h>
#include <algorithm>
@@ -518,12 +519,45 @@ bool Epub::generateCoverBmp(bool cropped) const {
LOG_ERR("EBP", "Failed to generate BMP from cover image");
Storage.remove(getCoverBmpPath(cropped).c_str());
}
LOG_DBG("EBP", "Generated BMP from cover image, success: %s", success ? "yes" : "no");
LOG_DBG("EBP", "Generated BMP from JPG cover image, success: %s", success ? "yes" : "no");
return success;
} else {
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
}
bool isPng = lowerHref.substr(lowerHref.length() - 4) == ".png";
if (isPng) {
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
FsFile coverPng;
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
return false;
}
readItemContentsToStream(effectiveCoverImageHref, coverPng, 1024);
coverPng.close();
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
return false;
}
FsFile coverBmp;
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
coverPng.close();
return false;
}
const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped);
coverPng.close();
coverBmp.close();
Storage.remove(coverPngTempPath.c_str());
if (!success) {
LOG_ERR("EBP", "Failed to generate BMP from PNG cover image");
Storage.remove(getCoverBmpPath(cropped).c_str());
}
LOG_DBG("EBP", "Generated BMP from PNG cover image, success: %s", success ? "yes" : "no");
return success;
}
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
return false;
}
@@ -611,9 +645,46 @@ bool Epub::generateThumbBmp(int height) const {
}
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
return success;
} else {
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
}
bool isPng = lowerHref.substr(lowerHref.length() - 4) == ".png";
if (isPng) {
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
const auto coverPngTempPath = getCachePath() + "/.cover.png";
FsFile coverPng;
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
return false;
}
readItemContentsToStream(effectiveCoverImageHref, coverPng, 1024);
coverPng.close();
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
return false;
}
FsFile thumbBmp;
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
coverPng.close();
return false;
}
int THUMB_TARGET_WIDTH = height * 0.6;
int THUMB_TARGET_HEIGHT = height;
const bool success =
PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
coverPng.close();
thumbBmp.close();
Storage.remove(coverPngTempPath.c_str());
if (!success) {
LOG_ERR("EBP", "Failed to generate thumb BMP from PNG cover image");
Storage.remove(getThumbBmpPath(height).c_str());
}
LOG_DBG("EBP", "Generated thumb BMP from PNG cover image, success: %s", success ? "yes" : "no");
return success;
}
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
}
return false;
@@ -970,7 +1041,7 @@ bool Epub::isValidThumbnailBmp(const std::string& bmpPath) {
std::vector<std::string> Epub::getCoverCandidates() const {
std::vector<std::string> coverDirectories = {".", "images", "Images", "OEBPS", "OEBPS/images", "OEBPS/Images"};
std::vector<std::string> coverExtensions = {".jpg", ".jpeg"}; // add ".png" when PNG cover support is implemented
std::vector<std::string> coverExtensions = {".jpg", ".jpeg", ".png"};
std::vector<std::string> coverCandidates;
for (const auto& ext : coverExtensions) {
for (const auto& dir : coverDirectories) {

View File

@@ -1,8 +1,7 @@
#include "ImageBlock.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <Logging.h>
#include <SDCardManager.h>
#include <Serialization.h>
@@ -47,8 +46,8 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
int widthDiff = abs(cachedWidth - expectedWidth);
int heightDiff = abs(cachedHeight - expectedHeight);
if (widthDiff > 1 || heightDiff > 1) {
Serial.printf("[%lu] [IMG] Cache dimension mismatch: %dx%d vs %dx%d\n", millis(), cachedWidth, cachedHeight,
expectedWidth, expectedHeight);
LOG_ERR("IMG", "Cache dimension mismatch: %dx%d vs %dx%d", cachedWidth, cachedHeight, expectedWidth,
expectedHeight);
cacheFile.close();
return false;
}
@@ -57,20 +56,20 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
expectedWidth = cachedWidth;
expectedHeight = cachedHeight;
Serial.printf("[%lu] [IMG] Loading from cache: %s (%dx%d)\n", millis(), cachePath.c_str(), cachedWidth, cachedHeight);
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
// Read and render row by row to minimize memory usage
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
if (!rowBuffer) {
Serial.printf("[%lu] [IMG] Failed to allocate row buffer\n", millis());
LOG_ERR("IMG", "Failed to allocate row buffer");
cacheFile.close();
return false;
}
for (int row = 0; row < cachedHeight; row++) {
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
Serial.printf("[%lu] [IMG] Cache read error at row %d\n", millis(), row);
LOG_ERR("IMG", "Cache read error at row %d", row);
free(rowBuffer);
cacheFile.close();
return false;
@@ -88,22 +87,22 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
free(rowBuffer);
cacheFile.close();
Serial.printf("[%lu] [IMG] Cache render complete\n", millis());
LOG_DBG("IMG", "Cache render complete");
return true;
}
} // namespace
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
Serial.printf("[%lu] [IMG] Rendering image at %d,%d: %s (%dx%d)\n", millis(), x, y, imagePath.c_str(), width, height);
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);
const int screenWidth = renderer.getScreenWidth();
const int screenHeight = renderer.getScreenHeight();
// Bounds check render position using logical screen dimensions
if (x < 0 || y < 0 || x + width > screenWidth || y + height > screenHeight) {
Serial.printf("[%lu] [IMG] Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)\n", millis(), x, y, width,
height, screenWidth, screenHeight);
LOG_ERR("IMG", "Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)", x, y, width, height, screenWidth,
screenHeight);
return;
}
@@ -117,18 +116,18 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
// Check if image file exists
FsFile file;
if (!Storage.openFileForRead("IMG", imagePath, file)) {
Serial.printf("[%lu] [IMG] Image file not found: %s\n", millis(), imagePath.c_str());
LOG_ERR("IMG", "Image file not found: %s", imagePath.c_str());
return;
}
size_t fileSize = file.size();
file.close();
if (fileSize == 0) {
Serial.printf("[%lu] [IMG] Image file is empty: %s\n", millis(), imagePath.c_str());
LOG_ERR("IMG", "Image file is empty: %s", imagePath.c_str());
return;
}
Serial.printf("[%lu] [IMG] Decoding and caching: %s\n", millis(), imagePath.c_str());
LOG_DBG("IMG", "Decoding and caching: %s", imagePath.c_str());
RenderConfig config;
config.x = x;
@@ -143,19 +142,19 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
if (!decoder) {
Serial.printf("[%lu] [IMG] No decoder found for image: %s\n", millis(), imagePath.c_str());
LOG_ERR("IMG", "No decoder found for image: %s", imagePath.c_str());
return;
}
Serial.printf("[%lu] [IMG] Using %s decoder\n", millis(), decoder->getFormatName());
LOG_DBG("IMG", "Using %s decoder", decoder->getFormatName());
bool success = decoder->decodeToFramebuffer(imagePath, renderer, config);
if (!success) {
Serial.printf("[%lu] [IMG] Failed to decode image: %s\n", millis(), imagePath.c_str());
LOG_ERR("IMG", "Failed to decode image: %s", imagePath.c_str());
return;
}
Serial.printf("[%lu] [IMG] Decode successful\n", millis());
LOG_DBG("IMG", "Decode successful");
}
bool ImageBlock::serialize(FsFile& file) {

View File

@@ -1,6 +1,6 @@
#include "ImageDecoderFactory.h"
#include <HardwareSerial.h>
#include <Logging.h>
#include <memory>
#include <string>
@@ -35,7 +35,7 @@ ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& im
return pngDecoder.get();
}
Serial.printf("[%lu] [DEC] No decoder found for image: %s\n", millis(), imagePath.c_str());
LOG_ERR("DEC", "No decoder found for image: %s", imagePath.c_str());
return nullptr;
}

View File

@@ -1,18 +1,17 @@
#include "ImageToFramebufferDecoder.h"
#include <Arduino.h>
#include <HardwareSerial.h>
#include <Logging.h>
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
if (width * height > MAX_SOURCE_PIXELS) {
Serial.printf("[%lu] [IMG] Image too large (%dx%d = %d pixels %s), max supported: %d pixels\n", millis(), width,
height, width * height, format.c_str(), MAX_SOURCE_PIXELS);
LOG_ERR("IMG", "Image too large (%dx%d = %d pixels %s), max supported: %d pixels", width, height, width * height,
format.c_str(), MAX_SOURCE_PIXELS);
return false;
}
return true;
}
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
Serial.printf("[%lu] [IMG] Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.\n",
millis(), feature.c_str(), imagePath.c_str());
LOG_ERR("IMG", "Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.", feature.c_str(),
imagePath.c_str());
}

View File

@@ -1,7 +1,7 @@
#include "JpegToFramebufferConverter.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <Logging.h>
#include <SDCardManager.h>
#include <SdFat.h>
#include <picojpeg.h>
@@ -23,7 +23,7 @@ struct JpegContext {
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
FsFile file;
if (!Storage.openFileForRead("JPG", imagePath, file)) {
Serial.printf("[%lu] [JPG] Failed to open file for dimensions: %s\n", millis(), imagePath.c_str());
LOG_ERR("JPG", "Failed to open file for dimensions: %s", imagePath.c_str());
return false;
}
@@ -34,23 +34,23 @@ bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePat
file.close();
if (status != 0) {
Serial.printf("[%lu] [JPG] Failed to init JPEG for dimensions: %d\n", millis(), status);
LOG_ERR("JPG", "Failed to init JPEG for dimensions: %d", status);
return false;
}
out.width = imageInfo.m_width;
out.height = imageInfo.m_height;
Serial.printf("[%lu] [JPG] Image dimensions: %dx%d\n", millis(), out.width, out.height);
LOG_DBG("JPG", "Image dimensions: %dx%d", out.width, out.height);
return true;
}
bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
const RenderConfig& config) {
Serial.printf("[%lu] [JPG] Decoding JPEG: %s\n", millis(), imagePath.c_str());
LOG_DBG("JPG", "Decoding JPEG: %s", imagePath.c_str());
FsFile file;
if (!Storage.openFileForRead("JPG", imagePath, file)) {
Serial.printf("[%lu] [JPG] Failed to open file: %s\n", millis(), imagePath.c_str());
LOG_ERR("JPG", "Failed to open file: %s", imagePath.c_str());
return false;
}
@@ -59,7 +59,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
if (status != 0) {
Serial.printf("[%lu] [JPG] picojpeg init failed: %d\n", millis(), status);
LOG_ERR("JPG", "picojpeg init failed: %d", status);
file.close();
return false;
}
@@ -93,12 +93,11 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
destHeight = (int)(imageInfo.m_height * scale);
}
Serial.printf("[%lu] [JPG] JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d\n", millis(),
imageInfo.m_width, imageInfo.m_height, destWidth, destHeight, scale, imageInfo.m_scanType,
imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d", imageInfo.m_width, imageInfo.m_height,
destWidth, destHeight, scale, imageInfo.m_scanType, imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) {
Serial.printf("[%lu] [JPG] Null buffer pointers in imageInfo\n", millis());
LOG_ERR("JPG", "Null buffer pointers in imageInfo");
file.close();
return false;
}
@@ -111,7 +110,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
bool caching = !config.cachePath.empty();
if (caching) {
if (!cache.allocate(destWidth, destHeight, config.x, config.y)) {
Serial.printf("[%lu] [JPG] Failed to allocate cache buffer, continuing without caching\n", millis());
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
caching = false;
}
}
@@ -125,7 +124,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
break;
}
if (status != 0) {
Serial.printf("[%lu] [JPG] MCU decode failed: %d\n", millis(), status);
LOG_ERR("JPG", "MCU decode failed: %d", status);
file.close();
return false;
}
@@ -254,7 +253,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
}
}
Serial.printf("[%lu] [JPG] Decoding complete\n", millis());
LOG_DBG("JPG", "Decoding complete");
file.close();
// Write cache file if caching was enabled

View File

@@ -1,8 +1,7 @@
#pragma once
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <SdFat.h>
#include <HalStorage.h>
#include <Logging.h>
#include <stdint.h>
#include <cstring>
@@ -32,14 +31,13 @@ struct PixelCache {
bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte
size_t bufferSize = (size_t)bytesPerRow * h;
if (bufferSize > MAX_CACHE_BYTES) {
Serial.printf("[%lu] [IMG] Cache buffer too large: %d bytes for %dx%d (limit %d)\n", millis(), bufferSize, w, h,
MAX_CACHE_BYTES);
LOG_ERR("IMG", "Cache buffer too large: %d bytes for %dx%d (limit %d)", bufferSize, w, h, MAX_CACHE_BYTES);
return false;
}
buffer = (uint8_t*)malloc(bufferSize);
if (buffer) {
memset(buffer, 0, bufferSize);
Serial.printf("[%lu] [IMG] Allocated cache buffer: %d bytes for %dx%d\n", millis(), bufferSize, w, h);
LOG_DBG("IMG", "Allocated cache buffer: %d bytes for %dx%d", bufferSize, w, h);
}
return buffer != nullptr;
}
@@ -60,7 +58,7 @@ struct PixelCache {
FsFile cacheFile;
if (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) {
Serial.printf("[%lu] [IMG] Failed to open cache file for writing: %s\n", millis(), cachePath.c_str());
LOG_ERR("IMG", "Failed to open cache file for writing: %s", cachePath.c_str());
return false;
}
@@ -71,8 +69,7 @@ struct PixelCache {
cacheFile.write(buffer, bytesPerRow * height);
cacheFile.close();
Serial.printf("[%lu] [IMG] Cache written: %s (%dx%d, %d bytes)\n", millis(), cachePath.c_str(), width, height,
4 + bytesPerRow * height);
LOG_DBG("IMG", "Cache written: %s (%dx%d, %d bytes)", cachePath.c_str(), width, height, 4 + bytesPerRow * height);
return true;
}

View File

@@ -1,7 +1,7 @@
#include "PngToFramebufferConverter.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <Logging.h>
#include <PNGdec.h>
#include <SDCardManager.h>
#include <SdFat.h>
@@ -216,14 +216,13 @@ int pngDrawCallback(PNGDRAW* pDraw) {
bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
size_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap,
MIN_FREE_HEAP_FOR_PNG);
LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG);
return false;
}
PNG* png = new (std::nothrow) PNG();
if (!png) {
Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder for dimensions\n", millis());
LOG_ERR("PNG", "Failed to allocate PNG decoder for dimensions");
return false;
}
@@ -231,7 +230,7 @@ bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath
nullptr);
if (rc != 0) {
Serial.printf("[%lu] [PNG] Failed to open PNG for dimensions: %d\n", millis(), rc);
LOG_ERR("PNG", "Failed to open PNG for dimensions: %d", rc);
delete png;
return false;
}
@@ -246,19 +245,18 @@ bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath
bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
const RenderConfig& config) {
Serial.printf("[%lu] [PNG] Decoding PNG: %s\n", millis(), imagePath.c_str());
LOG_DBG("PNG", "Decoding PNG: %s", imagePath.c_str());
size_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
Serial.printf("[%lu] [PNG] Not enough heap for PNG decoder (%u free, need %u)\n", millis(), freeHeap,
MIN_FREE_HEAP_FOR_PNG);
LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG);
return false;
}
// Heap-allocate PNG decoder (~42 KB) - freed at end of function
PNG* png = new (std::nothrow) PNG();
if (!png) {
Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder\n", millis());
LOG_ERR("PNG", "Failed to allocate PNG decoder");
return false;
}
@@ -271,7 +269,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
pngDrawCallback);
if (rc != PNG_SUCCESS) {
Serial.printf("[%lu] [PNG] Failed to open PNG: %d\n", millis(), rc);
LOG_ERR("PNG", "Failed to open PNG: %d", rc);
delete png;
return false;
}
@@ -303,8 +301,8 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
ctx.lastDstY = -1; // Reset row tracking
Serial.printf("[%lu] [PNG] PNG %dx%d -> %dx%d (scale %.2f), bpp: %d\n", millis(), ctx.srcWidth, ctx.srcHeight,
ctx.dstWidth, ctx.dstHeight, ctx.scale, png->getBpp());
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight,
ctx.scale, png->getBpp());
if (png->getBpp() != 8) {
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
@@ -314,7 +312,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
const size_t grayBufSize = PNG_MAX_BUFFERED_PIXELS / 2;
ctx.grayLineBuffer = static_cast<uint8_t*>(malloc(grayBufSize));
if (!ctx.grayLineBuffer) {
Serial.printf("[%lu] [PNG] Failed to allocate gray line buffer\n", millis());
LOG_ERR("PNG", "Failed to allocate gray line buffer");
png->close();
delete png;
return false;
@@ -324,7 +322,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
ctx.caching = !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) {
Serial.printf("[%lu] [PNG] Failed to allocate cache buffer, continuing without caching\n", millis());
LOG_ERR("PNG", "Failed to allocate cache buffer, continuing without caching");
ctx.caching = false;
}
}
@@ -337,7 +335,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
ctx.grayLineBuffer = nullptr;
if (rc != PNG_SUCCESS) {
Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc);
LOG_ERR("PNG", "Decode failed: %d", rc);
png->close();
delete png;
return false;
@@ -345,7 +343,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
png->close();
delete png;
Serial.printf("[%lu] [PNG] PNG decoding complete - render time: %lu ms\n", millis(), decodeTime);
LOG_DBG("PNG", "PNG decoding complete - render time: %lu ms", decodeTime);
// Write cache file if caching was enabled and buffer was allocated
if (ctx.caching) {

View File

@@ -0,0 +1,858 @@
#include "PngToBmpConverter.h"
#include <HalStorage.h>
#include <Logging.h>
#include <miniz.h>
#include <cstdio>
#include <cstring>
#include "BitmapHelpers.h"
// ============================================================================
// IMAGE PROCESSING OPTIONS - Same as JpegToBmpConverter for consistency
// ============================================================================
constexpr bool USE_8BIT_OUTPUT = false;
constexpr bool USE_ATKINSON = true;
constexpr bool USE_FLOYD_STEINBERG = false;
constexpr bool USE_PRESCALE = true;
constexpr int TARGET_MAX_WIDTH = 480;
constexpr int TARGET_MAX_HEIGHT = 800;
// ============================================================================
// PNG constants
static constexpr uint8_t PNG_SIGNATURE[8] = {137, 80, 78, 71, 13, 10, 26, 10};
// PNG color types
enum PngColorType : uint8_t {
PNG_COLOR_GRAYSCALE = 0,
PNG_COLOR_RGB = 2,
PNG_COLOR_PALETTE = 3,
PNG_COLOR_GRAYSCALE_ALPHA = 4,
PNG_COLOR_RGBA = 6,
};
// PNG filter types
enum PngFilter : uint8_t {
PNG_FILTER_NONE = 0,
PNG_FILTER_SUB = 1,
PNG_FILTER_UP = 2,
PNG_FILTER_AVERAGE = 3,
PNG_FILTER_PAETH = 4,
};
// Read a big-endian 32-bit value from file
static bool readBE32(FsFile& file, uint32_t& value) {
uint8_t buf[4];
if (file.read(buf, 4) != 4) return false;
value = (static_cast<uint32_t>(buf[0]) << 24) | (static_cast<uint32_t>(buf[1]) << 16) |
(static_cast<uint32_t>(buf[2]) << 8) | buf[3];
return true;
}
// BMP writing helpers (same as JpegToBmpConverter)
inline void write16(Print& out, const uint16_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
}
inline void write32(Print& out, const uint32_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF);
out.write((value >> 24) & 0xFF);
}
inline void write32Signed(Print& out, const int32_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF);
out.write((value >> 24) & 0xFF);
}
static void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) {
const int bytesPerRow = (width + 3) / 4 * 4;
const int imageSize = bytesPerRow * height;
const uint32_t paletteSize = 256 * 4;
const uint32_t fileSize = 14 + 40 + paletteSize + imageSize;
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize);
write32(bmpOut, 0);
write32(bmpOut, 14 + 40 + paletteSize);
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height);
write16(bmpOut, 1);
write16(bmpOut, 8);
write32(bmpOut, 0);
write32(bmpOut, imageSize);
write32(bmpOut, 2835);
write32(bmpOut, 2835);
write32(bmpOut, 256);
write32(bmpOut, 256);
for (int i = 0; i < 256; i++) {
bmpOut.write(static_cast<uint8_t>(i));
bmpOut.write(static_cast<uint8_t>(i));
bmpOut.write(static_cast<uint8_t>(i));
bmpOut.write(static_cast<uint8_t>(0));
}
}
static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
const int bytesPerRow = (width + 31) / 32 * 4;
const int imageSize = bytesPerRow * height;
const uint32_t fileSize = 62 + imageSize;
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize);
write32(bmpOut, 0);
write32(bmpOut, 62);
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height);
write16(bmpOut, 1);
write16(bmpOut, 1);
write32(bmpOut, 0);
write32(bmpOut, imageSize);
write32(bmpOut, 2835);
write32(bmpOut, 2835);
write32(bmpOut, 2);
write32(bmpOut, 2);
uint8_t palette[8] = {0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00};
for (const uint8_t i : palette) {
bmpOut.write(i);
}
}
static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) {
const int bytesPerRow = (width * 2 + 31) / 32 * 4;
const int imageSize = bytesPerRow * height;
const uint32_t fileSize = 70 + imageSize;
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize);
write32(bmpOut, 0);
write32(bmpOut, 70);
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height);
write16(bmpOut, 1);
write16(bmpOut, 2);
write32(bmpOut, 0);
write32(bmpOut, imageSize);
write32(bmpOut, 2835);
write32(bmpOut, 2835);
write32(bmpOut, 4);
write32(bmpOut, 4);
uint8_t palette[16] = {0x00, 0x00, 0x00, 0x00, 0x55, 0x55, 0x55, 0x00,
0xAA, 0xAA, 0xAA, 0x00, 0xFF, 0xFF, 0xFF, 0x00};
for (const uint8_t i : palette) {
bmpOut.write(i);
}
}
// Paeth predictor function per PNG spec
static inline uint8_t paethPredictor(uint8_t a, uint8_t b, uint8_t c) {
int p = static_cast<int>(a) + b - c;
int pa = p > a ? p - a : a - p;
int pb = p > b ? p - b : b - p;
int pc = p > c ? p - c : c - p;
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
// Context for streaming PNG decompression
struct PngDecodeContext {
FsFile& file;
// PNG image properties
uint32_t width;
uint32_t height;
uint8_t bitDepth;
uint8_t colorType;
uint8_t bytesPerPixel; // after expanding sub-byte depths
uint32_t rawRowBytes; // bytes per raw row (without filter byte)
// Scanline buffers
uint8_t* currentRow; // current defiltered scanline
uint8_t* previousRow; // previous defiltered scanline
// zlib decompression state
mz_stream zstream;
bool zstreamInitialized;
// Chunk reading state
uint32_t chunkBytesRemaining; // bytes left in current IDAT chunk
bool idatFinished; // no more IDAT chunks
// File read buffer for feeding zlib
uint8_t readBuf[2048];
// Palette for indexed color (type 3)
uint8_t palette[256 * 3];
int paletteSize;
};
// Read the next IDAT chunk header, skipping non-IDAT chunks
// Returns true if an IDAT chunk was found
static bool findNextIdatChunk(PngDecodeContext& ctx) {
while (true) {
uint32_t chunkLen;
if (!readBE32(ctx.file, chunkLen)) return false;
uint8_t chunkType[4];
if (ctx.file.read(chunkType, 4) != 4) return false;
if (memcmp(chunkType, "IDAT", 4) == 0) {
ctx.chunkBytesRemaining = chunkLen;
return true;
}
// Skip this chunk's data + 4-byte CRC
// Use seek to skip efficiently
if (!ctx.file.seekCur(chunkLen + 4)) return false;
// If we hit IEND, there are no more chunks
if (memcmp(chunkType, "IEND", 4) == 0) {
return false;
}
}
}
// Feed compressed data to zlib from IDAT chunks
// Returns number of bytes made available in zstream, or -1 on error
static int feedZlibInput(PngDecodeContext& ctx) {
if (ctx.idatFinished) return 0;
// If current IDAT chunk is exhausted, skip its CRC and find next
while (ctx.chunkBytesRemaining == 0) {
// Skip 4-byte CRC of previous IDAT
if (!ctx.file.seekCur(4)) return -1;
if (!findNextIdatChunk(ctx)) {
ctx.idatFinished = true;
return 0;
}
}
// Read from current IDAT chunk
size_t toRead = sizeof(ctx.readBuf);
if (toRead > ctx.chunkBytesRemaining) toRead = ctx.chunkBytesRemaining;
int bytesRead = ctx.file.read(ctx.readBuf, toRead);
if (bytesRead <= 0) return -1;
ctx.chunkBytesRemaining -= bytesRead;
ctx.zstream.next_in = ctx.readBuf;
ctx.zstream.avail_in = bytesRead;
return bytesRead;
}
// Decompress exactly 'needed' bytes into 'dest'
static bool decompressBytes(PngDecodeContext& ctx, uint8_t* dest, size_t needed) {
ctx.zstream.next_out = dest;
ctx.zstream.avail_out = needed;
while (ctx.zstream.avail_out > 0) {
if (ctx.zstream.avail_in == 0) {
int fed = feedZlibInput(ctx);
if (fed < 0) return false;
if (fed == 0) {
// Try one more inflate to flush
int ret = mz_inflate(&ctx.zstream, MZ_SYNC_FLUSH);
if (ctx.zstream.avail_out == 0) break;
return false;
}
}
int ret = mz_inflate(&ctx.zstream, MZ_SYNC_FLUSH);
if (ret != MZ_OK && ret != MZ_STREAM_END && ret != MZ_BUF_ERROR) {
LOG_ERR("PNG", "zlib inflate error: %d", ret);
return false;
}
if (ret == MZ_STREAM_END) break;
}
return ctx.zstream.avail_out == 0;
}
// Decode one scanline: decompress filter byte + raw bytes, then unfilter
static bool decodeScanline(PngDecodeContext& ctx) {
// Decompress filter byte
uint8_t filterType;
if (!decompressBytes(ctx, &filterType, 1)) return false;
// Decompress raw row data into currentRow
if (!decompressBytes(ctx, ctx.currentRow, ctx.rawRowBytes)) return false;
// Apply reverse filter
const int bpp = ctx.bytesPerPixel;
switch (filterType) {
case PNG_FILTER_NONE:
break;
case PNG_FILTER_SUB:
for (uint32_t i = bpp; i < ctx.rawRowBytes; i++) {
ctx.currentRow[i] += ctx.currentRow[i - bpp];
}
break;
case PNG_FILTER_UP:
for (uint32_t i = 0; i < ctx.rawRowBytes; i++) {
ctx.currentRow[i] += ctx.previousRow[i];
}
break;
case PNG_FILTER_AVERAGE:
for (uint32_t i = 0; i < ctx.rawRowBytes; i++) {
uint8_t a = (i >= static_cast<uint32_t>(bpp)) ? ctx.currentRow[i - bpp] : 0;
uint8_t b = ctx.previousRow[i];
ctx.currentRow[i] += (a + b) / 2;
}
break;
case PNG_FILTER_PAETH:
for (uint32_t i = 0; i < ctx.rawRowBytes; i++) {
uint8_t a = (i >= static_cast<uint32_t>(bpp)) ? ctx.currentRow[i - bpp] : 0;
uint8_t b = ctx.previousRow[i];
uint8_t c = (i >= static_cast<uint32_t>(bpp)) ? ctx.previousRow[i - bpp] : 0;
ctx.currentRow[i] += paethPredictor(a, b, c);
}
break;
default:
LOG_ERR("PNG", "Unknown filter type: %d", filterType);
return false;
}
return true;
}
// Batch-convert an entire scanline to grayscale.
// Branches once on colorType/bitDepth, then runs a tight loop for the whole row.
static void convertScanlineToGray(const PngDecodeContext& ctx, uint8_t* grayRow) {
const uint8_t* src = ctx.currentRow;
const uint32_t w = ctx.width;
switch (ctx.colorType) {
case PNG_COLOR_GRAYSCALE:
if (ctx.bitDepth == 8) {
memcpy(grayRow, src, w);
} else if (ctx.bitDepth == 16) {
for (uint32_t x = 0; x < w; x++) grayRow[x] = src[x * 2];
} else {
const int ppb = 8 / ctx.bitDepth;
const uint8_t mask = (1 << ctx.bitDepth) - 1;
for (uint32_t x = 0; x < w; x++) {
int shift = (ppb - 1 - (x % ppb)) * ctx.bitDepth;
grayRow[x] = (src[x / ppb] >> shift & mask) * 255 / mask;
}
}
break;
case PNG_COLOR_RGB:
if (ctx.bitDepth == 8) {
// Fast path: most common EPUB cover format
for (uint32_t x = 0; x < w; x++) {
const uint8_t* p = src + x * 3;
grayRow[x] = (p[0] * 25 + p[1] * 50 + p[2] * 25) / 100;
}
} else {
for (uint32_t x = 0; x < w; x++) {
grayRow[x] = (src[x * 6] * 25 + src[x * 6 + 2] * 50 + src[x * 6 + 4] * 25) / 100;
}
}
break;
case PNG_COLOR_PALETTE: {
const int ppb = 8 / ctx.bitDepth;
const uint8_t mask = (1 << ctx.bitDepth) - 1;
const uint8_t* pal = ctx.palette;
const int palSize = ctx.paletteSize;
for (uint32_t x = 0; x < w; x++) {
int shift = (ppb - 1 - (x % ppb)) * ctx.bitDepth;
uint8_t idx = (src[x / ppb] >> shift) & mask;
if (idx >= palSize) idx = 0;
grayRow[x] = (pal[idx * 3] * 25 + pal[idx * 3 + 1] * 50 + pal[idx * 3 + 2] * 25) / 100;
}
break;
}
case PNG_COLOR_GRAYSCALE_ALPHA:
if (ctx.bitDepth == 8) {
for (uint32_t x = 0; x < w; x++) grayRow[x] = src[x * 2];
} else {
for (uint32_t x = 0; x < w; x++) grayRow[x] = src[x * 4];
}
break;
case PNG_COLOR_RGBA:
if (ctx.bitDepth == 8) {
for (uint32_t x = 0; x < w; x++) {
const uint8_t* p = src + x * 4;
grayRow[x] = (p[0] * 25 + p[1] * 50 + p[2] * 25) / 100;
}
} else {
for (uint32_t x = 0; x < w; x++) {
grayRow[x] = (src[x * 8] * 25 + src[x * 8 + 2] * 50 + src[x * 8 + 4] * 25) / 100;
}
}
break;
default:
memset(grayRow, 128, w);
break;
}
}
bool PngToBmpConverter::pngFileToBmpStreamInternal(FsFile& pngFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit, bool crop) {
LOG_DBG("PNG", "Converting PNG to %s BMP (target: %dx%d)", oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight);
// Verify PNG signature
uint8_t sig[8];
if (pngFile.read(sig, 8) != 8 || memcmp(sig, PNG_SIGNATURE, 8) != 0) {
LOG_ERR("PNG", "Invalid PNG signature");
return false;
}
// Read IHDR chunk
uint32_t ihdrLen;
if (!readBE32(pngFile, ihdrLen)) return false;
uint8_t ihdrType[4];
if (pngFile.read(ihdrType, 4) != 4 || memcmp(ihdrType, "IHDR", 4) != 0) {
LOG_ERR("PNG", "Missing IHDR chunk");
return false;
}
uint32_t width, height;
if (!readBE32(pngFile, width) || !readBE32(pngFile, height)) return false;
uint8_t ihdrRest[5];
if (pngFile.read(ihdrRest, 5) != 5) return false;
uint8_t bitDepth = ihdrRest[0];
uint8_t colorType = ihdrRest[1];
uint8_t compression = ihdrRest[2];
uint8_t filter = ihdrRest[3];
uint8_t interlace = ihdrRest[4];
// Skip IHDR CRC
pngFile.seekCur(4);
LOG_DBG("PNG", "Image: %ux%u, depth=%u, color=%u, interlace=%u", width, height, bitDepth, colorType, interlace);
if (compression != 0 || filter != 0) {
LOG_ERR("PNG", "Unsupported compression/filter method");
return false;
}
if (interlace != 0) {
LOG_ERR("PNG", "Interlaced PNGs not supported");
return false;
}
// Safety limits
constexpr int MAX_IMAGE_WIDTH = 2048;
constexpr int MAX_IMAGE_HEIGHT = 3072;
if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT || width == 0 || height == 0) {
LOG_ERR("PNG", "Image too large or zero (%ux%u)", width, height);
return false;
}
// Calculate bytes per pixel and raw row bytes
uint8_t bytesPerPixel;
uint32_t rawRowBytes;
switch (colorType) {
case PNG_COLOR_GRAYSCALE:
if (bitDepth == 16) {
bytesPerPixel = 2;
rawRowBytes = width * 2;
} else if (bitDepth == 8) {
bytesPerPixel = 1;
rawRowBytes = width;
} else {
// Sub-byte: 1, 2, or 4 bits
bytesPerPixel = 1;
rawRowBytes = (width * bitDepth + 7) / 8;
}
break;
case PNG_COLOR_RGB:
bytesPerPixel = (bitDepth == 16) ? 6 : 3;
rawRowBytes = width * bytesPerPixel;
break;
case PNG_COLOR_PALETTE:
bytesPerPixel = 1;
rawRowBytes = (width * bitDepth + 7) / 8;
break;
case PNG_COLOR_GRAYSCALE_ALPHA:
bytesPerPixel = (bitDepth == 16) ? 4 : 2;
rawRowBytes = width * bytesPerPixel;
break;
case PNG_COLOR_RGBA:
bytesPerPixel = (bitDepth == 16) ? 8 : 4;
rawRowBytes = width * bytesPerPixel;
break;
default:
LOG_ERR("PNG", "Unsupported color type: %d", colorType);
return false;
}
// Validate raw row bytes won't cause memory issues
if (rawRowBytes > 16384) {
LOG_ERR("PNG", "Row too large: %u bytes", rawRowBytes);
return false;
}
// Initialize decode context
PngDecodeContext ctx = {.file = pngFile,
.width = width,
.height = height,
.bitDepth = bitDepth,
.colorType = colorType,
.bytesPerPixel = bytesPerPixel,
.rawRowBytes = rawRowBytes,
.currentRow = nullptr,
.previousRow = nullptr,
.zstream = {},
.zstreamInitialized = false,
.chunkBytesRemaining = 0,
.idatFinished = false,
.readBuf = {},
.palette = {},
.paletteSize = 0};
// Allocate scanline buffers
ctx.currentRow = static_cast<uint8_t*>(malloc(rawRowBytes));
ctx.previousRow = static_cast<uint8_t*>(calloc(rawRowBytes, 1));
if (!ctx.currentRow || !ctx.previousRow) {
LOG_ERR("PNG", "Failed to allocate scanline buffers (%u bytes each)", rawRowBytes);
free(ctx.currentRow);
free(ctx.previousRow);
return false;
}
// Scan for PLTE chunk (palette) and first IDAT chunk
// We need to read chunks until we find IDAT, collecting PLTE along the way
bool foundIdat = false;
while (!foundIdat) {
uint32_t chunkLen;
if (!readBE32(pngFile, chunkLen)) break;
uint8_t chunkType[4];
if (pngFile.read(chunkType, 4) != 4) break;
if (memcmp(chunkType, "PLTE", 4) == 0) {
int entries = chunkLen / 3;
if (entries > 256) entries = 256;
ctx.paletteSize = entries;
size_t palBytes = entries * 3;
pngFile.read(ctx.palette, palBytes);
// Skip any remaining palette data
if (chunkLen > palBytes) pngFile.seekCur(chunkLen - palBytes);
pngFile.seekCur(4); // CRC
} else if (memcmp(chunkType, "IDAT", 4) == 0) {
ctx.chunkBytesRemaining = chunkLen;
foundIdat = true;
} else if (memcmp(chunkType, "IEND", 4) == 0) {
break;
} else {
// Skip unknown chunk
pngFile.seekCur(chunkLen + 4);
}
}
if (!foundIdat) {
LOG_ERR("PNG", "No IDAT chunk found");
free(ctx.currentRow);
free(ctx.previousRow);
return false;
}
// Initialize zlib decompression
memset(&ctx.zstream, 0, sizeof(ctx.zstream));
if (mz_inflateInit(&ctx.zstream) != MZ_OK) {
LOG_ERR("PNG", "Failed to initialize zlib");
free(ctx.currentRow);
free(ctx.previousRow);
return false;
}
ctx.zstreamInitialized = true;
// Calculate output dimensions (same logic as JpegToBmpConverter)
int outWidth = width;
int outHeight = height;
uint32_t scaleX_fp = 65536;
uint32_t scaleY_fp = 65536;
bool needsScaling = false;
if (targetWidth > 0 && targetHeight > 0 &&
(static_cast<int>(width) > targetWidth || static_cast<int>(height) > targetHeight)) {
const float scaleToFitWidth = static_cast<float>(targetWidth) / width;
const float scaleToFitHeight = static_cast<float>(targetHeight) / height;
float scale = 1.0;
if (crop) {
scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
} else {
scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
}
outWidth = static_cast<int>(width * scale);
outHeight = static_cast<int>(height * scale);
if (outWidth < 1) outWidth = 1;
if (outHeight < 1) outHeight = 1;
scaleX_fp = (static_cast<uint32_t>(width) << 16) / outWidth;
scaleY_fp = (static_cast<uint32_t>(height) << 16) / outHeight;
needsScaling = true;
LOG_DBG("PNG", "Pre-scaling %ux%u -> %dx%d (fit to %dx%d)", width, height, outWidth, outHeight, targetWidth,
targetHeight);
}
// Write BMP header
int bytesPerRow;
if (USE_8BIT_OUTPUT && !oneBit) {
writeBmpHeader8bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 3) / 4 * 4;
} else if (oneBit) {
writeBmpHeader1bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 31) / 32 * 4;
} else {
writeBmpHeader2bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth * 2 + 31) / 32 * 4;
}
// Allocate BMP row buffer
auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow));
if (!rowBuffer) {
LOG_ERR("PNG", "Failed to allocate row buffer");
mz_inflateEnd(&ctx.zstream);
free(ctx.currentRow);
free(ctx.previousRow);
return false;
}
// Create ditherers (same as JpegToBmpConverter)
AtkinsonDitherer* atkinsonDitherer = nullptr;
FloydSteinbergDitherer* fsDitherer = nullptr;
Atkinson1BitDitherer* atkinson1BitDitherer = nullptr;
if (oneBit) {
atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth);
} else if (!USE_8BIT_OUTPUT) {
if (USE_ATKINSON) {
atkinsonDitherer = new AtkinsonDitherer(outWidth);
} else if (USE_FLOYD_STEINBERG) {
fsDitherer = new FloydSteinbergDitherer(outWidth);
}
}
// Scaling accumulators
uint32_t* rowAccum = nullptr;
uint16_t* rowCount = nullptr;
int currentOutY = 0;
uint32_t nextOutY_srcStart = 0;
if (needsScaling) {
rowAccum = new uint32_t[outWidth]();
rowCount = new uint16_t[outWidth]();
nextOutY_srcStart = scaleY_fp;
}
// Allocate grayscale row buffer - batch-convert each scanline to avoid
// per-pixel getPixelGray() switch overhead in the hot loops
auto* grayRow = static_cast<uint8_t*>(malloc(width));
if (!grayRow) {
LOG_ERR("PNG", "Failed to allocate grayscale row buffer");
delete[] rowAccum;
delete[] rowCount;
delete atkinsonDitherer;
delete fsDitherer;
delete atkinson1BitDitherer;
free(rowBuffer);
mz_inflateEnd(&ctx.zstream);
free(ctx.currentRow);
free(ctx.previousRow);
return false;
}
bool success = true;
// Process each scanline
for (uint32_t y = 0; y < height; y++) {
// Decode one scanline
if (!decodeScanline(ctx)) {
LOG_ERR("PNG", "Failed to decode scanline %u", y);
success = false;
break;
}
// Batch-convert entire scanline to grayscale (one branch, tight loop)
convertScanlineToGray(ctx, grayRow);
if (!needsScaling) {
// Direct output (no scaling)
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT && !oneBit) {
for (int x = 0; x < outWidth; x++) {
rowBuffer[x] = adjustPixel(grayRow[x]);
}
} else if (oneBit) {
for (int x = 0; x < outWidth; x++) {
const uint8_t bit =
atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(grayRow[x], x) : quantize1bit(grayRow[x], x, y);
const int byteIndex = x / 8;
const int bitOffset = 7 - (x % 8);
rowBuffer[byteIndex] |= (bit << bitOffset);
}
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
} else {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = adjustPixel(grayRow[x]);
uint8_t twoBit;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x);
} else {
twoBit = quantize(gray, x, y);
}
const int byteIndex = (x * 2) / 8;
const int bitOffset = 6 - ((x * 2) % 8);
rowBuffer[byteIndex] |= (twoBit << bitOffset);
}
if (atkinsonDitherer)
atkinsonDitherer->nextRow();
else if (fsDitherer)
fsDitherer->nextRow();
}
bmpOut.write(rowBuffer, bytesPerRow);
} else {
// Area-averaging scaling (same as JpegToBmpConverter)
for (int outX = 0; outX < outWidth; outX++) {
const int srcXStart = (static_cast<uint32_t>(outX) * scaleX_fp) >> 16;
const int srcXEnd = (static_cast<uint32_t>(outX + 1) * scaleX_fp) >> 16;
int sum = 0;
int count = 0;
for (int srcX = srcXStart; srcX < srcXEnd && srcX < static_cast<int>(width); srcX++) {
sum += grayRow[srcX];
count++;
}
if (count == 0 && srcXStart < static_cast<int>(width)) {
sum = grayRow[srcXStart];
count = 1;
}
rowAccum[outX] += sum;
rowCount[outX] += count;
}
// Check if we've crossed into the next output row
const uint32_t srcY_fp = static_cast<uint32_t>(y + 1) << 16;
if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) {
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT && !oneBit) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
rowBuffer[x] = adjustPixel(gray);
}
} else if (oneBit) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
const uint8_t bit =
atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, currentOutY);
const int byteIndex = x / 8;
const int bitOffset = 7 - (x % 8);
rowBuffer[byteIndex] |= (bit << bitOffset);
}
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
} else {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0);
uint8_t twoBit;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x);
} else {
twoBit = quantize(gray, x, currentOutY);
}
const int byteIndex = (x * 2) / 8;
const int bitOffset = 6 - ((x * 2) % 8);
rowBuffer[byteIndex] |= (twoBit << bitOffset);
}
if (atkinsonDitherer)
atkinsonDitherer->nextRow();
else if (fsDitherer)
fsDitherer->nextRow();
}
bmpOut.write(rowBuffer, bytesPerRow);
currentOutY++;
memset(rowAccum, 0, outWidth * sizeof(uint32_t));
memset(rowCount, 0, outWidth * sizeof(uint16_t));
nextOutY_srcStart = static_cast<uint32_t>(currentOutY + 1) * scaleY_fp;
}
}
// Swap current/previous row buffers
uint8_t* temp = ctx.previousRow;
ctx.previousRow = ctx.currentRow;
ctx.currentRow = temp;
}
// Clean up
free(grayRow);
delete[] rowAccum;
delete[] rowCount;
delete atkinsonDitherer;
delete fsDitherer;
delete atkinson1BitDitherer;
free(rowBuffer);
mz_inflateEnd(&ctx.zstream);
free(ctx.currentRow);
free(ctx.previousRow);
if (success) {
LOG_DBG("PNG", "Successfully converted PNG to BMP");
}
return success;
}
bool PngToBmpConverter::pngFileToBmpStream(FsFile& pngFile, Print& bmpOut, bool crop) {
return pngFileToBmpStreamInternal(pngFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop);
}
bool PngToBmpConverter::pngFileToBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth,
int targetMaxHeight) {
return pngFileToBmpStreamInternal(pngFile, bmpOut, targetMaxWidth, targetMaxHeight, false);
}
bool PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth,
int targetMaxHeight) {
return pngFileToBmpStreamInternal(pngFile, bmpOut, targetMaxWidth, targetMaxHeight, true, true);
}

View File

@@ -0,0 +1,14 @@
#pragma once
class FsFile;
class Print;
class PngToBmpConverter {
static bool pngFileToBmpStreamInternal(FsFile& pngFile, Print& bmpOut, int targetWidth, int targetHeight, bool oneBit,
bool crop = true);
public:
static bool pngFileToBmpStream(FsFile& pngFile, Print& bmpOut, bool crop = true);
static bool pngFileToBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
static bool pngFileTo1BitBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
};