mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 14:47:37 +03:00
feat: epub add png jpeg support
This commit is contained in:
parent
4dd73a211a
commit
f807953ee3
@ -25,6 +25,29 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
|
|||||||
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
|
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||||
|
// Images don't use fontId or text rendering
|
||||||
|
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PageImage::serialize(FsFile& file) {
|
||||||
|
serialization::writePod(file, xPos);
|
||||||
|
serialization::writePod(file, yPos);
|
||||||
|
|
||||||
|
// serialize ImageBlock
|
||||||
|
return imageBlock->serialize(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
|
||||||
|
int16_t xPos;
|
||||||
|
int16_t yPos;
|
||||||
|
serialization::readPod(file, xPos);
|
||||||
|
serialization::readPod(file, yPos);
|
||||||
|
|
||||||
|
auto ib = ImageBlock::deserialize(file);
|
||||||
|
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos));
|
||||||
|
}
|
||||||
|
|
||||||
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
||||||
for (auto& element : elements) {
|
for (auto& element : elements) {
|
||||||
element->render(renderer, fontId, xOffset, yOffset);
|
element->render(renderer, fontId, xOffset, yOffset);
|
||||||
@ -36,8 +59,9 @@ bool Page::serialize(FsFile& file) const {
|
|||||||
serialization::writePod(file, count);
|
serialization::writePod(file, count);
|
||||||
|
|
||||||
for (const auto& el : elements) {
|
for (const auto& el : elements) {
|
||||||
// Only PageLine exists currently
|
// Use getTag() method to determine type
|
||||||
serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
|
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
|
||||||
|
|
||||||
if (!el->serialize(file)) {
|
if (!el->serialize(file)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -59,6 +83,9 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
|
|||||||
if (tag == TAG_PageLine) {
|
if (tag == TAG_PageLine) {
|
||||||
auto pl = PageLine::deserialize(file);
|
auto pl = PageLine::deserialize(file);
|
||||||
page->elements.push_back(std::move(pl));
|
page->elements.push_back(std::move(pl));
|
||||||
|
} else if (tag == TAG_PageImage) {
|
||||||
|
auto pi = PageImage::deserialize(file);
|
||||||
|
page->elements.push_back(std::move(pi));
|
||||||
} else {
|
} else {
|
||||||
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag);
|
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
|||||||
@ -4,10 +4,12 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "blocks/ImageBlock.h"
|
||||||
#include "blocks/TextBlock.h"
|
#include "blocks/TextBlock.h"
|
||||||
|
|
||||||
enum PageElementTag : uint8_t {
|
enum PageElementTag : uint8_t {
|
||||||
TAG_PageLine = 1,
|
TAG_PageLine = 1,
|
||||||
|
TAG_PageImage = 2, // New tag
|
||||||
};
|
};
|
||||||
|
|
||||||
// represents something that has been added to a page
|
// represents something that has been added to a page
|
||||||
@ -19,6 +21,7 @@ class PageElement {
|
|||||||
virtual ~PageElement() = default;
|
virtual ~PageElement() = default;
|
||||||
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
|
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
|
||||||
virtual bool serialize(FsFile& file) = 0;
|
virtual bool serialize(FsFile& file) = 0;
|
||||||
|
virtual PageElementTag getTag() const = 0; // Add type identification
|
||||||
};
|
};
|
||||||
|
|
||||||
// a line from a block element
|
// a line from a block element
|
||||||
@ -30,9 +33,23 @@ class PageLine final : public PageElement {
|
|||||||
: PageElement(xPos, yPos), block(std::move(block)) {}
|
: PageElement(xPos, yPos), block(std::move(block)) {}
|
||||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||||
bool serialize(FsFile& file) override;
|
bool serialize(FsFile& file) override;
|
||||||
|
PageElementTag getTag() const override { return TAG_PageLine; }
|
||||||
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// New PageImage class
|
||||||
|
class PageImage final : public PageElement {
|
||||||
|
std::shared_ptr<ImageBlock> imageBlock;
|
||||||
|
|
||||||
|
public:
|
||||||
|
PageImage(std::shared_ptr<ImageBlock> block, const int16_t xPos, const int16_t yPos)
|
||||||
|
: PageElement(xPos, yPos), imageBlock(std::move(block)) {}
|
||||||
|
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||||
|
bool serialize(FsFile& file) override;
|
||||||
|
PageElementTag getTag() const override { return TAG_PageImage; }
|
||||||
|
static std::unique_ptr<PageImage> deserialize(FsFile& file);
|
||||||
|
};
|
||||||
|
|
||||||
class Page {
|
class Page {
|
||||||
public:
|
public:
|
||||||
// the list of block index and line numbers on this page
|
// the list of block index and line numbers on this page
|
||||||
|
|||||||
@ -177,7 +177,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
std::vector<uint32_t> lut = {};
|
std::vector<uint32_t> lut = {};
|
||||||
|
|
||||||
ChapterHtmlSlimParser visitor(
|
ChapterHtmlSlimParser visitor(
|
||||||
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
viewportHeight, hyphenationEnabled,
|
viewportHeight, hyphenationEnabled,
|
||||||
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, popupFn);
|
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, popupFn);
|
||||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||||
|
|||||||
182
lib/Epub/Epub/blocks/ImageBlock.cpp
Normal file
182
lib/Epub/Epub/blocks/ImageBlock.cpp
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
#include "ImageBlock.h"
|
||||||
|
|
||||||
|
#include <FsHelpers.h>
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <Serialization.h>
|
||||||
|
|
||||||
|
#include "../converters/ImageDecoderFactory.h"
|
||||||
|
|
||||||
|
// Cache file format:
|
||||||
|
// - uint16_t width
|
||||||
|
// - uint16_t height
|
||||||
|
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
|
||||||
|
|
||||||
|
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
|
||||||
|
: imagePath(imagePath), width(width), height(height) {}
|
||||||
|
|
||||||
|
bool ImageBlock::imageExists() const {
|
||||||
|
FsFile file;
|
||||||
|
return SdMan.openFileForRead("IMG", imagePath, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImageBlock::layout(GfxRenderer& renderer) {}
|
||||||
|
|
||||||
|
static std::string getCachePath(const std::string& imagePath) {
|
||||||
|
// Replace extension with .pxc (pixel cache)
|
||||||
|
size_t dotPos = imagePath.rfind('.');
|
||||||
|
if (dotPos != std::string::npos) {
|
||||||
|
return imagePath.substr(0, dotPos) + ".pxc";
|
||||||
|
}
|
||||||
|
return imagePath + ".pxc";
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
||||||
|
int expectedHeight) {
|
||||||
|
FsFile cacheFile;
|
||||||
|
if (!SdMan.openFileForRead("IMG", cachePath, cacheFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t cachedWidth, cachedHeight;
|
||||||
|
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify dimensions are close (allow 1 pixel tolerance for rounding differences)
|
||||||
|
int widthDiff = abs(cachedWidth - expectedWidth);
|
||||||
|
int heightDiff = abs(cachedHeight - expectedHeight);
|
||||||
|
if (widthDiff > 1 || heightDiff > 1) {
|
||||||
|
Serial.printf("[%lu] [IMG] Cache dimension mismatch: %dx%d vs %dx%d\n", millis(), cachedWidth, cachedHeight,
|
||||||
|
expectedWidth, expectedHeight);
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached dimensions for rendering (they're the actual decoded size)
|
||||||
|
expectedWidth = cachedWidth;
|
||||||
|
expectedHeight = cachedHeight;
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [IMG] Loading from cache: %s (%dx%d)\n", millis(), cachePath.c_str(), cachedWidth, cachedHeight);
|
||||||
|
|
||||||
|
// Read and render row by row to minimize memory usage
|
||||||
|
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||||
|
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
|
||||||
|
if (!rowBuffer) {
|
||||||
|
Serial.printf("[%lu] [IMG] Failed to allocate row buffer\n", millis());
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int row = 0; row < cachedHeight; row++) {
|
||||||
|
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
|
||||||
|
Serial.printf("[%lu] [IMG] Cache read error at row %d\n", millis(), row);
|
||||||
|
free(rowBuffer);
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int destY = y + row;
|
||||||
|
GfxRenderer::RenderMode renderMode = renderer.getRenderMode();
|
||||||
|
for (int col = 0; col < cachedWidth; col++) {
|
||||||
|
int byteIdx = col / 4;
|
||||||
|
int bitShift = 6 - (col % 4) * 2; // MSB first within byte
|
||||||
|
uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
|
||||||
|
|
||||||
|
// Draw based on render mode (same logic as GfxRenderer::drawBitmap)
|
||||||
|
if (renderMode == GfxRenderer::BW && pixelValue < 3) {
|
||||||
|
renderer.drawPixel(x + col, destY, true);
|
||||||
|
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (pixelValue == 1 || pixelValue == 2)) {
|
||||||
|
renderer.drawPixel(x + col, destY, false);
|
||||||
|
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && pixelValue == 1) {
|
||||||
|
renderer.drawPixel(x + col, destY, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
free(rowBuffer);
|
||||||
|
cacheFile.close();
|
||||||
|
Serial.printf("[%lu] [IMG] Cache render complete\n", millis());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||||
|
Serial.printf("[%lu] [IMG] Rendering image at %d,%d: %s (%dx%d)\n", millis(), x, y, imagePath.c_str(), width, height);
|
||||||
|
|
||||||
|
const int screenWidth = renderer.getScreenWidth();
|
||||||
|
const int screenHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
|
// Bounds check render position using logical screen dimensions
|
||||||
|
if (x < 0 || y < 0 || x + width > screenWidth || y + height > screenHeight) {
|
||||||
|
Serial.printf("[%lu] [IMG] Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)\n", millis(), x, y, width,
|
||||||
|
height, screenWidth, screenHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to render from cache first
|
||||||
|
std::string cachePath = getCachePath(imagePath);
|
||||||
|
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
|
||||||
|
return; // Successfully rendered from cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache - need to decode the image
|
||||||
|
// Check if image file exists
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForRead("IMG", imagePath, file)) {
|
||||||
|
Serial.printf("[%lu] [IMG] Image file not found: %s\n", millis(), imagePath.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
size_t fileSize = file.size();
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (fileSize == 0) {
|
||||||
|
Serial.printf("[%lu] [IMG] Image file is empty: %s\n", millis(), imagePath.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [IMG] Decoding and caching: %s\n", millis(), imagePath.c_str());
|
||||||
|
|
||||||
|
RenderConfig config;
|
||||||
|
config.x = x;
|
||||||
|
config.y = y;
|
||||||
|
config.maxWidth = width;
|
||||||
|
config.maxHeight = height;
|
||||||
|
config.useGrayscale = true;
|
||||||
|
config.useDithering = true;
|
||||||
|
config.performanceMode = false;
|
||||||
|
config.cachePath = cachePath; // Enable caching during decode
|
||||||
|
|
||||||
|
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
|
||||||
|
if (!decoder) {
|
||||||
|
Serial.printf("[%lu] [IMG] No decoder found for image: %s\n", millis(), imagePath.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [IMG] Using %s decoder\n", millis(), decoder->getFormatName());
|
||||||
|
|
||||||
|
bool success = decoder->decodeToFramebuffer(imagePath, renderer, config);
|
||||||
|
if (!success) {
|
||||||
|
Serial.printf("[%lu] [IMG] Failed to decode image: %s\n", millis(), imagePath.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [IMG] Decode successful\n", millis());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImageBlock::serialize(FsFile& file) {
|
||||||
|
serialization::writeString(file, imagePath);
|
||||||
|
serialization::writePod(file, width);
|
||||||
|
serialization::writePod(file, height);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<ImageBlock> ImageBlock::deserialize(FsFile& file) {
|
||||||
|
std::string path;
|
||||||
|
serialization::readString(file, path);
|
||||||
|
int16_t w, h;
|
||||||
|
serialization::readPod(file, w);
|
||||||
|
serialization::readPod(file, h);
|
||||||
|
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
|
||||||
|
}
|
||||||
32
lib/Epub/Epub/blocks/ImageBlock.h
Normal file
32
lib/Epub/Epub/blocks/ImageBlock.h
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <SdFat.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "Block.h"
|
||||||
|
|
||||||
|
class ImageBlock final : public Block {
|
||||||
|
public:
|
||||||
|
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
|
||||||
|
~ImageBlock() override = default;
|
||||||
|
|
||||||
|
const std::string& getImagePath() const { return imagePath; }
|
||||||
|
int16_t getWidth() const { return width; }
|
||||||
|
int16_t getHeight() const { return height; }
|
||||||
|
|
||||||
|
bool imageExists() const;
|
||||||
|
|
||||||
|
void layout(GfxRenderer& renderer) override;
|
||||||
|
BlockType getType() override { return IMAGE_BLOCK; }
|
||||||
|
bool isEmpty() override { return false; }
|
||||||
|
|
||||||
|
void render(GfxRenderer& renderer, const int x, const int y);
|
||||||
|
bool serialize(FsFile& file);
|
||||||
|
static std::unique_ptr<ImageBlock> deserialize(FsFile& file);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string imagePath;
|
||||||
|
int16_t width;
|
||||||
|
int16_t height;
|
||||||
|
};
|
||||||
31
lib/Epub/Epub/converters/FramebufferWriter.cpp
Normal file
31
lib/Epub/Epub/converters/FramebufferWriter.cpp
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
#include "FramebufferWriter.h"
|
||||||
|
|
||||||
|
void FramebufferWriter::setPixel(int x, int y, bool isBlack) {
|
||||||
|
if (x < 0 || x >= DISPLAY_WIDTH || y < 0 || y >= DISPLAY_HEIGHT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint16_t byteIndex = y * DISPLAY_WIDTH_BYTES + (x / 8);
|
||||||
|
const uint8_t bitPosition = 7 - (x % 8);
|
||||||
|
|
||||||
|
if (isBlack) {
|
||||||
|
frameBuffer[byteIndex] &= ~(1 << bitPosition);
|
||||||
|
} else {
|
||||||
|
frameBuffer[byteIndex] |= (1 << bitPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FramebufferWriter::setPixel2Bit(int x, int y, uint8_t value) {
|
||||||
|
if (x < 0 || x >= DISPLAY_WIDTH || y < 0 || y >= DISPLAY_HEIGHT || value > 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint16_t byteIndex = y * DISPLAY_WIDTH_BYTES + (x / 8);
|
||||||
|
const uint8_t bitPosition = 7 - (x % 8);
|
||||||
|
|
||||||
|
if (value < 2) {
|
||||||
|
frameBuffer[byteIndex] &= ~(1 << bitPosition);
|
||||||
|
} else {
|
||||||
|
frameBuffer[byteIndex] |= (1 << bitPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/Epub/Epub/converters/FramebufferWriter.h
Normal file
19
lib/Epub/Epub/converters/FramebufferWriter.h
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
class FramebufferWriter {
|
||||||
|
private:
|
||||||
|
uint8_t* frameBuffer;
|
||||||
|
static constexpr int DISPLAY_WIDTH = 800;
|
||||||
|
static constexpr int DISPLAY_WIDTH_BYTES = DISPLAY_WIDTH / 8; // 100
|
||||||
|
static constexpr int DISPLAY_HEIGHT = 480;
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit FramebufferWriter(uint8_t* framebuffer) : frameBuffer(framebuffer) {}
|
||||||
|
|
||||||
|
// Simple pixel setting for 1-bit rendering
|
||||||
|
void setPixel(int x, int y, bool isBlack);
|
||||||
|
|
||||||
|
// 2-bit grayscale pixel setting (for dual-pass rendering)
|
||||||
|
void setPixel2Bit(int x, int y, uint8_t value); // value: 0-3
|
||||||
|
};
|
||||||
68
lib/Epub/Epub/converters/ImageDecoderFactory.cpp
Normal file
68
lib/Epub/Epub/converters/ImageDecoderFactory.cpp
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
#include "ImageDecoderFactory.h"
|
||||||
|
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "JpegToFramebufferConverter.h"
|
||||||
|
#include "PngToFramebufferConverter.h"
|
||||||
|
|
||||||
|
std::unique_ptr<JpegToFramebufferConverter> ImageDecoderFactory::jpegDecoder = nullptr;
|
||||||
|
std::unique_ptr<PngToFramebufferConverter> ImageDecoderFactory::pngDecoder = nullptr;
|
||||||
|
bool ImageDecoderFactory::initialized = false;
|
||||||
|
|
||||||
|
void ImageDecoderFactory::initialize() {
|
||||||
|
if (initialized) return;
|
||||||
|
|
||||||
|
jpegDecoder = std::unique_ptr<JpegToFramebufferConverter>(new JpegToFramebufferConverter());
|
||||||
|
pngDecoder = std::unique_ptr<PngToFramebufferConverter>(new PngToFramebufferConverter());
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
Serial.printf("[%lu] [DEC] Image decoder factory initialized\n", millis());
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& imagePath) {
|
||||||
|
if (!initialized) {
|
||||||
|
initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ext = imagePath;
|
||||||
|
size_t dotPos = ext.rfind('.');
|
||||||
|
if (dotPos != std::string::npos) {
|
||||||
|
ext = ext.substr(dotPos);
|
||||||
|
for (auto& c : ext) {
|
||||||
|
c = tolower(c);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ext = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jpegDecoder && jpegDecoder->supportsFormat(ext)) {
|
||||||
|
return jpegDecoder.get();
|
||||||
|
} else if (pngDecoder && pngDecoder->supportsFormat(ext)) {
|
||||||
|
return pngDecoder.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [DEC] No decoder found for image: %s\n", millis(), imagePath.c_str());
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImageDecoderFactory::isFormatSupported(const std::string& imagePath) {
|
||||||
|
if (jpegDecoder && jpegDecoder->supportsFormat(imagePath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (pngDecoder && pngDecoder->supportsFormat(imagePath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> ImageDecoderFactory::getSupportedFormats() {
|
||||||
|
std::vector<std::string> formats;
|
||||||
|
formats.push_back(".jpg");
|
||||||
|
formats.push_back(".jpeg");
|
||||||
|
formats.push_back(".png");
|
||||||
|
return formats;
|
||||||
|
}
|
||||||
24
lib/Epub/Epub/converters/ImageDecoderFactory.h
Normal file
24
lib/Epub/Epub/converters/ImageDecoderFactory.h
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "ImageToFramebufferDecoder.h"
|
||||||
|
|
||||||
|
class JpegToFramebufferConverter;
|
||||||
|
class PngToFramebufferConverter;
|
||||||
|
|
||||||
|
class ImageDecoderFactory {
|
||||||
|
public:
|
||||||
|
static void initialize();
|
||||||
|
// Returns non-owning pointer - factory owns the decoder lifetime
|
||||||
|
static ImageToFramebufferDecoder* getDecoder(const std::string& imagePath);
|
||||||
|
static bool isFormatSupported(const std::string& imagePath);
|
||||||
|
static std::vector<std::string> getSupportedFormats();
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::unique_ptr<JpegToFramebufferConverter> jpegDecoder;
|
||||||
|
static std::unique_ptr<PngToFramebufferConverter> pngDecoder;
|
||||||
|
static bool initialized;
|
||||||
|
};
|
||||||
18
lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp
Normal file
18
lib/Epub/Epub/converters/ImageToFramebufferDecoder.cpp
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
#include "ImageToFramebufferDecoder.h"
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
|
||||||
|
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
|
||||||
|
if (width > MAX_SOURCE_WIDTH || height > MAX_SOURCE_HEIGHT) {
|
||||||
|
Serial.printf("[%lu] [IMG] Image too large (%dx%d %s), max supported: %dx%d\n", millis(), width, height,
|
||||||
|
format.c_str(), MAX_SOURCE_WIDTH, MAX_SOURCE_HEIGHT);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
|
||||||
|
Serial.printf("[%lu] [IMG] Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.\n",
|
||||||
|
millis(), feature.c_str(), imagePath.c_str());
|
||||||
|
}
|
||||||
41
lib/Epub/Epub/converters/ImageToFramebufferDecoder.h
Normal file
41
lib/Epub/Epub/converters/ImageToFramebufferDecoder.h
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <SdFat.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class GfxRenderer;
|
||||||
|
|
||||||
|
struct ImageDimensions {
|
||||||
|
int16_t width;
|
||||||
|
int16_t height;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RenderConfig {
|
||||||
|
int x, y;
|
||||||
|
int maxWidth, maxHeight;
|
||||||
|
bool useGrayscale = true;
|
||||||
|
bool useDithering = true;
|
||||||
|
bool performanceMode = false;
|
||||||
|
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImageToFramebufferDecoder {
|
||||||
|
public:
|
||||||
|
virtual ~ImageToFramebufferDecoder() = default;
|
||||||
|
|
||||||
|
virtual bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) = 0;
|
||||||
|
|
||||||
|
virtual bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const = 0;
|
||||||
|
|
||||||
|
virtual bool supportsFormat(const std::string& extension) const = 0;
|
||||||
|
virtual const char* getFormatName() const = 0;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// Size validation helpers
|
||||||
|
static constexpr int MAX_SOURCE_WIDTH = 2048;
|
||||||
|
static constexpr int MAX_SOURCE_HEIGHT = 1536;
|
||||||
|
|
||||||
|
bool validateImageDimensions(int width, int height, const std::string& format);
|
||||||
|
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
|
||||||
|
};
|
||||||
385
lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp
Normal file
385
lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
#include "JpegToFramebufferConverter.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <SdFat.h>
|
||||||
|
#include <picojpeg.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
struct JpegContext {
|
||||||
|
FsFile& file;
|
||||||
|
uint8_t buffer[512];
|
||||||
|
size_t bufferPos;
|
||||||
|
size_t bufferFilled;
|
||||||
|
JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache buffer for storing 2-bit pixels during decode
|
||||||
|
struct PixelCache {
|
||||||
|
uint8_t* buffer;
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
|
int bytesPerRow;
|
||||||
|
int originX; // config.x - to convert screen coords to cache coords
|
||||||
|
int originY; // config.y
|
||||||
|
|
||||||
|
PixelCache() : buffer(nullptr), width(0), height(0), bytesPerRow(0), originX(0), originY(0) {}
|
||||||
|
|
||||||
|
bool allocate(int w, int h, int ox, int oy) {
|
||||||
|
width = w;
|
||||||
|
height = h;
|
||||||
|
originX = ox;
|
||||||
|
originY = oy;
|
||||||
|
bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||||
|
size_t bufferSize = bytesPerRow * h;
|
||||||
|
buffer = (uint8_t*)malloc(bufferSize);
|
||||||
|
if (buffer) {
|
||||||
|
memset(buffer, 0, bufferSize);
|
||||||
|
Serial.printf("[%lu] [JPG] Allocated cache buffer: %d bytes for %dx%d\n", millis(), bufferSize, w, h);
|
||||||
|
}
|
||||||
|
return buffer != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPixel(int screenX, int screenY, uint8_t value) {
|
||||||
|
if (!buffer) return;
|
||||||
|
int localX = screenX - originX;
|
||||||
|
int localY = screenY - originY;
|
||||||
|
if (localX < 0 || localX >= width || localY < 0 || localY >= height) return;
|
||||||
|
|
||||||
|
int byteIdx = localY * bytesPerRow + localX / 4;
|
||||||
|
int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7
|
||||||
|
buffer[byteIdx] = (buffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool writeToFile(const std::string& cachePath) {
|
||||||
|
if (!buffer) return false;
|
||||||
|
|
||||||
|
FsFile cacheFile;
|
||||||
|
if (!SdMan.openFileForWrite("IMG", cachePath, cacheFile)) {
|
||||||
|
Serial.printf("[%lu] [JPG] Failed to open cache file for writing: %s\n", millis(), cachePath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t w = width;
|
||||||
|
uint16_t h = height;
|
||||||
|
cacheFile.write(&w, 2);
|
||||||
|
cacheFile.write(&h, 2);
|
||||||
|
cacheFile.write(buffer, bytesPerRow * height);
|
||||||
|
cacheFile.close();
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [JPG] Cache written: %s (%dx%d, %d bytes)\n", millis(), cachePath.c_str(), width, height,
|
||||||
|
4 + bytesPerRow * height);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
~PixelCache() {
|
||||||
|
if (buffer) {
|
||||||
|
free(buffer);
|
||||||
|
buffer = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4x4 Bayer matrix for ordered dithering
|
||||||
|
static const uint8_t bayer4x4[4][4] = {
|
||||||
|
{0, 8, 2, 10},
|
||||||
|
{12, 4, 14, 6},
|
||||||
|
{3, 11, 1, 9},
|
||||||
|
{15, 7, 13, 5},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply Bayer dithering and quantize to 4 levels (0-3)
|
||||||
|
// Stateless - works correctly with any pixel processing order (ideal for MCU-based decoding)
|
||||||
|
static uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
|
||||||
|
int bayer = bayer4x4[y & 3][x & 3];
|
||||||
|
int dither = (bayer - 8) * 5; // Scale to ±40 (half of quantization step 85)
|
||||||
|
|
||||||
|
int adjusted = gray + dither;
|
||||||
|
if (adjusted < 0) adjusted = 0;
|
||||||
|
if (adjusted > 255) adjusted = 255;
|
||||||
|
|
||||||
|
if (adjusted < 64) return 0;
|
||||||
|
if (adjusted < 128) return 1;
|
||||||
|
if (adjusted < 192) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a pixel respecting the current render mode for grayscale support
|
||||||
|
static void drawPixelWithRenderMode(GfxRenderer& renderer, int x, int y, uint8_t pixelValue) {
|
||||||
|
GfxRenderer::RenderMode renderMode = renderer.getRenderMode();
|
||||||
|
if (renderMode == GfxRenderer::BW && pixelValue < 3) {
|
||||||
|
renderer.drawPixel(x, y, true);
|
||||||
|
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (pixelValue == 1 || pixelValue == 2)) {
|
||||||
|
renderer.drawPixel(x, y, false);
|
||||||
|
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && pixelValue == 1) {
|
||||||
|
renderer.drawPixel(x, y, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForRead("JPG", imagePath, file)) {
|
||||||
|
Serial.printf("[%lu] [JPG] Failed to open file for dimensions: %s\n", millis(), imagePath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JpegContext context(file);
|
||||||
|
pjpeg_image_info_t imageInfo;
|
||||||
|
|
||||||
|
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (status != 0) {
|
||||||
|
Serial.printf("[%lu] [JPG] Failed to init JPEG for dimensions: %d\n", millis(), status);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.width = imageInfo.m_width;
|
||||||
|
out.height = imageInfo.m_height;
|
||||||
|
Serial.printf("[%lu] [JPG] Image dimensions: %dx%d\n", millis(), out.width, out.height);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
||||||
|
const RenderConfig& config) {
|
||||||
|
Serial.printf("[%lu] [JPG] Decoding JPEG: %s\n", millis(), imagePath.c_str());
|
||||||
|
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForRead("JPG", imagePath, file)) {
|
||||||
|
Serial.printf("[%lu] [JPG] Failed to open file: %s\n", millis(), imagePath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JpegContext context(file);
|
||||||
|
pjpeg_image_info_t imageInfo;
|
||||||
|
|
||||||
|
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
|
||||||
|
if (status != 0) {
|
||||||
|
Serial.printf("[%lu] [JPG] picojpeg init failed: %d\n", millis(), status);
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateImageDimensions(imageInfo.m_width, imageInfo.m_height, "JPEG")) {
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate scale factor to fit within maxWidth/maxHeight
|
||||||
|
float scaleX =
|
||||||
|
(config.maxWidth > 0 && imageInfo.m_width > config.maxWidth) ? (float)config.maxWidth / imageInfo.m_width : 1.0f;
|
||||||
|
float scaleY = (config.maxHeight > 0 && imageInfo.m_height > config.maxHeight)
|
||||||
|
? (float)config.maxHeight / imageInfo.m_height
|
||||||
|
: 1.0f;
|
||||||
|
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||||
|
if (scale > 1.0f) scale = 1.0f;
|
||||||
|
|
||||||
|
int destWidth = (int)(imageInfo.m_width * scale);
|
||||||
|
int destHeight = (int)(imageInfo.m_height * scale);
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [JPG] JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d\n", millis(),
|
||||||
|
imageInfo.m_width, imageInfo.m_height, destWidth, destHeight, scale, imageInfo.m_scanType,
|
||||||
|
imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
|
||||||
|
|
||||||
|
if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) {
|
||||||
|
Serial.printf("[%lu] [JPG] Null buffer pointers in imageInfo\n", millis());
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int screenWidth = renderer.getScreenWidth();
|
||||||
|
const int screenHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
|
// Allocate pixel cache if cachePath is provided
|
||||||
|
PixelCache cache;
|
||||||
|
bool caching = !config.cachePath.empty();
|
||||||
|
if (caching) {
|
||||||
|
if (!cache.allocate(destWidth, destHeight, config.x, config.y)) {
|
||||||
|
Serial.printf("[%lu] [JPG] Failed to allocate cache buffer, continuing without caching\n", millis());
|
||||||
|
caching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int mcuX = 0;
|
||||||
|
int mcuY = 0;
|
||||||
|
|
||||||
|
while (mcuY < imageInfo.m_MCUSPerCol) {
|
||||||
|
status = pjpeg_decode_mcu();
|
||||||
|
if (status == PJPG_NO_MORE_BLOCKS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (status != 0) {
|
||||||
|
Serial.printf("[%lu] [JPG] MCU decode failed: %d\n", millis(), status);
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source position in image coordinates
|
||||||
|
int srcStartX = mcuX * imageInfo.m_MCUWidth;
|
||||||
|
int srcStartY = mcuY * imageInfo.m_MCUHeight;
|
||||||
|
|
||||||
|
switch (imageInfo.m_scanType) {
|
||||||
|
case PJPG_GRAYSCALE:
|
||||||
|
for (int row = 0; row < 8; row++) {
|
||||||
|
int srcY = srcStartY + row;
|
||||||
|
int destY = config.y + (int)(srcY * scale);
|
||||||
|
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||||
|
for (int col = 0; col < 8; col++) {
|
||||||
|
int srcX = srcStartX + col;
|
||||||
|
int destX = config.x + (int)(srcX * scale);
|
||||||
|
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||||
|
uint8_t gray = imageInfo.m_pMCUBufR[row * 8 + col];
|
||||||
|
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||||
|
if (dithered > 3) dithered = 3;
|
||||||
|
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||||
|
if (caching) cache.setPixel(destX, destY, dithered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PJPG_YH1V1:
|
||||||
|
for (int row = 0; row < 8; row++) {
|
||||||
|
int srcY = srcStartY + row;
|
||||||
|
int destY = config.y + (int)(srcY * scale);
|
||||||
|
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||||
|
for (int col = 0; col < 8; col++) {
|
||||||
|
int srcX = srcStartX + col;
|
||||||
|
int destX = config.x + (int)(srcX * scale);
|
||||||
|
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||||
|
uint8_t r = imageInfo.m_pMCUBufR[row * 8 + col];
|
||||||
|
uint8_t g = imageInfo.m_pMCUBufG[row * 8 + col];
|
||||||
|
uint8_t b = imageInfo.m_pMCUBufB[row * 8 + col];
|
||||||
|
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||||
|
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||||
|
if (dithered > 3) dithered = 3;
|
||||||
|
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||||
|
if (caching) cache.setPixel(destX, destY, dithered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PJPG_YH2V1:
|
||||||
|
for (int row = 0; row < 8; row++) {
|
||||||
|
int srcY = srcStartY + row;
|
||||||
|
int destY = config.y + (int)(srcY * scale);
|
||||||
|
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||||
|
for (int col = 0; col < 16; col++) {
|
||||||
|
int srcX = srcStartX + col;
|
||||||
|
int destX = config.x + (int)(srcX * scale);
|
||||||
|
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||||
|
int blockIndex = (col < 8) ? 0 : 1;
|
||||||
|
int pixelIndex = row * 8 + (col % 8);
|
||||||
|
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 64 + pixelIndex];
|
||||||
|
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 64 + pixelIndex];
|
||||||
|
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 64 + pixelIndex];
|
||||||
|
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||||
|
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||||
|
if (dithered > 3) dithered = 3;
|
||||||
|
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||||
|
if (caching) cache.setPixel(destX, destY, dithered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PJPG_YH1V2:
|
||||||
|
for (int row = 0; row < 16; row++) {
|
||||||
|
int srcY = srcStartY + row;
|
||||||
|
int destY = config.y + (int)(srcY * scale);
|
||||||
|
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||||
|
for (int col = 0; col < 8; col++) {
|
||||||
|
int srcX = srcStartX + col;
|
||||||
|
int destX = config.x + (int)(srcX * scale);
|
||||||
|
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||||
|
int blockIndex = (row < 8) ? 0 : 1;
|
||||||
|
int pixelIndex = (row % 8) * 8 + col;
|
||||||
|
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 128 + pixelIndex];
|
||||||
|
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 128 + pixelIndex];
|
||||||
|
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 128 + pixelIndex];
|
||||||
|
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||||
|
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||||
|
if (dithered > 3) dithered = 3;
|
||||||
|
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||||
|
if (caching) cache.setPixel(destX, destY, dithered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PJPG_YH2V2:
|
||||||
|
for (int row = 0; row < 16; row++) {
|
||||||
|
int srcY = srcStartY + row;
|
||||||
|
int destY = config.y + (int)(srcY * scale);
|
||||||
|
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||||
|
for (int col = 0; col < 16; col++) {
|
||||||
|
int srcX = srcStartX + col;
|
||||||
|
int destX = config.x + (int)(srcX * scale);
|
||||||
|
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||||
|
int blockX = (col < 8) ? 0 : 1;
|
||||||
|
int blockY = (row < 8) ? 0 : 1;
|
||||||
|
int blockIndex = blockY * 2 + blockX;
|
||||||
|
int pixelIndex = (row % 8) * 8 + (col % 8);
|
||||||
|
int blockOffset = blockIndex * 64;
|
||||||
|
uint8_t r = imageInfo.m_pMCUBufR[blockOffset + pixelIndex];
|
||||||
|
uint8_t g = imageInfo.m_pMCUBufG[blockOffset + pixelIndex];
|
||||||
|
uint8_t b = imageInfo.m_pMCUBufB[blockOffset + pixelIndex];
|
||||||
|
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||||
|
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||||
|
if (dithered > 3) dithered = 3;
|
||||||
|
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||||
|
if (caching) cache.setPixel(destX, destY, dithered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
mcuX++;
|
||||||
|
if (mcuX >= imageInfo.m_MCUSPerRow) {
|
||||||
|
mcuX = 0;
|
||||||
|
mcuY++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [JPG] Decoding complete\n", millis());
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
// Write cache file if caching was enabled
|
||||||
|
if (caching) {
|
||||||
|
cache.writeToFile(config.cachePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned char JpegToFramebufferConverter::jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
|
||||||
|
unsigned char* pBytes_actually_read, void* pCallback_data) {
|
||||||
|
JpegContext* context = reinterpret_cast<JpegContext*>(pCallback_data);
|
||||||
|
|
||||||
|
if (context->bufferPos >= context->bufferFilled) {
|
||||||
|
int readCount = context->file.read(context->buffer, sizeof(context->buffer));
|
||||||
|
if (readCount <= 0) {
|
||||||
|
*pBytes_actually_read = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
context->bufferFilled = readCount;
|
||||||
|
context->bufferPos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned int bytesAvailable = context->bufferFilled - context->bufferPos;
|
||||||
|
unsigned int bytesToCopy = (bytesAvailable < buf_size) ? bytesAvailable : buf_size;
|
||||||
|
|
||||||
|
memcpy(pBuf, &context->buffer[context->bufferPos], bytesToCopy);
|
||||||
|
context->bufferPos += bytesToCopy;
|
||||||
|
*pBytes_actually_read = bytesToCopy;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) const {
|
||||||
|
std::string ext = extension;
|
||||||
|
for (auto& c : ext) {
|
||||||
|
c = tolower(c);
|
||||||
|
}
|
||||||
|
return (ext == ".jpg" || ext == ".jpeg");
|
||||||
|
}
|
||||||
24
lib/Epub/Epub/converters/JpegToFramebufferConverter.h
Normal file
24
lib/Epub/Epub/converters/JpegToFramebufferConverter.h
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ImageToFramebufferDecoder.h"
|
||||||
|
|
||||||
|
class JpegToFramebufferConverter final : public ImageToFramebufferDecoder {
|
||||||
|
public:
|
||||||
|
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
|
||||||
|
|
||||||
|
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
|
||||||
|
|
||||||
|
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
|
||||||
|
return getDimensionsStatic(imagePath, dims);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool supportsFormat(const std::string& extension) const override;
|
||||||
|
const char* getFormatName() const override { return "JPEG"; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
|
||||||
|
unsigned char* pBytes_actually_read, void* pCallback_data);
|
||||||
|
};
|
||||||
354
lib/Epub/Epub/converters/PngToFramebufferConverter.cpp
Normal file
354
lib/Epub/Epub/converters/PngToFramebufferConverter.cpp
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
#include "PngToFramebufferConverter.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
#include <PNGdec.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <SdFat.h>
|
||||||
|
|
||||||
|
static FsFile* gPngFile = nullptr;
|
||||||
|
|
||||||
|
static void* pngOpenForDims(const char* filename, int32_t* size) { return gPngFile; }
|
||||||
|
|
||||||
|
static void pngCloseForDims(void* handle) {}
|
||||||
|
|
||||||
|
static int32_t pngReadForDims(PNGFILE* pFile, uint8_t* pBuf, int32_t len) {
|
||||||
|
if (!gPngFile) return 0;
|
||||||
|
return gPngFile->read(pBuf, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int32_t pngSeekForDims(PNGFILE* pFile, int32_t pos) {
|
||||||
|
if (!gPngFile) return -1;
|
||||||
|
return gPngFile->seek(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single static PNG object shared between getDimensions and decode
|
||||||
|
// (these operations never happen simultaneously)
|
||||||
|
static PNG png;
|
||||||
|
|
||||||
|
bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForRead("PNG", imagePath, file)) {
|
||||||
|
Serial.printf("[%lu] [PNG] Failed to open file for dimensions: %s\n", millis(), imagePath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
gPngFile = &file;
|
||||||
|
|
||||||
|
int rc = png.open(imagePath.c_str(), pngOpenForDims, pngCloseForDims, pngReadForDims, pngSeekForDims, nullptr);
|
||||||
|
|
||||||
|
if (rc != 0) {
|
||||||
|
Serial.printf("[%lu] [PNG] Failed to open PNG for dimensions: %d\n", millis(), rc);
|
||||||
|
file.close();
|
||||||
|
gPngFile = nullptr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.width = png.getWidth();
|
||||||
|
out.height = png.getHeight();
|
||||||
|
|
||||||
|
png.close();
|
||||||
|
file.close();
|
||||||
|
gPngFile = nullptr;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
static GfxRenderer* gRenderer = nullptr;
|
||||||
|
static const RenderConfig* gConfig = nullptr;
|
||||||
|
static int gScreenWidth = 0;
|
||||||
|
static int gScreenHeight = 0;
|
||||||
|
static FsFile* pngFile = nullptr;
|
||||||
|
|
||||||
|
// Scaling state for PNG
|
||||||
|
static float gScale = 1.0f;
|
||||||
|
static int gSrcWidth = 0;
|
||||||
|
static int gSrcHeight = 0;
|
||||||
|
static int gDstWidth = 0;
|
||||||
|
static int gDstHeight = 0;
|
||||||
|
static int gLastDstY = -1; // Track last rendered destination Y to avoid duplicates
|
||||||
|
|
||||||
|
// Pixel cache for PNG (uses scaled dimensions)
|
||||||
|
static uint8_t* gCacheBuffer = nullptr;
|
||||||
|
static int gCacheWidth = 0;
|
||||||
|
static int gCacheHeight = 0;
|
||||||
|
static int gCacheBytesPerRow = 0;
|
||||||
|
static int gCacheOriginX = 0;
|
||||||
|
static int gCacheOriginY = 0;
|
||||||
|
|
||||||
|
static void cacheSetPixel(int screenX, int screenY, uint8_t value) {
|
||||||
|
if (!gCacheBuffer) return;
|
||||||
|
int localX = screenX - gCacheOriginX;
|
||||||
|
int localY = screenY - gCacheOriginY;
|
||||||
|
if (localX < 0 || localX >= gCacheWidth || localY < 0 || localY >= gCacheHeight) return;
|
||||||
|
|
||||||
|
int byteIdx = localY * gCacheBytesPerRow + localX / 4;
|
||||||
|
int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7
|
||||||
|
gCacheBuffer[byteIdx] = (gCacheBuffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4x4 Bayer matrix for ordered dithering
|
||||||
|
static const uint8_t bayer4x4[4][4] = {
|
||||||
|
{0, 8, 2, 10},
|
||||||
|
{12, 4, 14, 6},
|
||||||
|
{3, 11, 1, 9},
|
||||||
|
{15, 7, 13, 5},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply Bayer dithering and quantize to 4 levels (0-3)
|
||||||
|
// Stateless - works correctly with any pixel processing order
|
||||||
|
static uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
|
||||||
|
int bayer = bayer4x4[y & 3][x & 3];
|
||||||
|
int dither = (bayer - 8) * 5; // Scale to ±40 (half of quantization step 85)
|
||||||
|
|
||||||
|
int adjusted = gray + dither;
|
||||||
|
if (adjusted < 0) adjusted = 0;
|
||||||
|
if (adjusted > 255) adjusted = 255;
|
||||||
|
|
||||||
|
if (adjusted < 64) return 0;
|
||||||
|
if (adjusted < 128) return 1;
|
||||||
|
if (adjusted < 192) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a pixel respecting the current render mode for grayscale support
|
||||||
|
static void drawPixelWithRenderMode(GfxRenderer* renderer, int x, int y, uint8_t pixelValue) {
|
||||||
|
GfxRenderer::RenderMode renderMode = renderer->getRenderMode();
|
||||||
|
if (renderMode == GfxRenderer::BW && pixelValue < 3) {
|
||||||
|
renderer->drawPixel(x, y, true);
|
||||||
|
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (pixelValue == 1 || pixelValue == 2)) {
|
||||||
|
renderer->drawPixel(x, y, false);
|
||||||
|
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && pixelValue == 1) {
|
||||||
|
renderer->drawPixel(x, y, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void* pngOpen(const char* filename, int32_t* size) {
|
||||||
|
pngFile = new FsFile();
|
||||||
|
if (!SdMan.openFileForRead("PNG", std::string(filename), *pngFile)) {
|
||||||
|
delete pngFile;
|
||||||
|
pngFile = nullptr;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
*size = pngFile->size();
|
||||||
|
return pngFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
void pngClose(void* handle) {
|
||||||
|
if (pngFile) {
|
||||||
|
pngFile->close();
|
||||||
|
delete pngFile;
|
||||||
|
pngFile = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t pngRead(PNGFILE* pFile, uint8_t* pBuf, int32_t len) {
|
||||||
|
if (!pngFile) return 0;
|
||||||
|
return pngFile->read(pBuf, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t pngSeek(PNGFILE* pFile, int32_t pos) {
|
||||||
|
if (!pngFile) return -1;
|
||||||
|
return pngFile->seek(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get grayscale from PNG pixel data
|
||||||
|
static uint8_t getGrayFromPixel(uint8_t* pPixels, int x, int pixelType, uint8_t* palette) {
|
||||||
|
switch (pixelType) {
|
||||||
|
case PNG_PIXEL_GRAYSCALE:
|
||||||
|
return pPixels[x];
|
||||||
|
|
||||||
|
case PNG_PIXEL_TRUECOLOR: {
|
||||||
|
uint8_t* p = &pPixels[x * 3];
|
||||||
|
return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
case PNG_PIXEL_INDEXED: {
|
||||||
|
uint8_t paletteIndex = pPixels[x];
|
||||||
|
if (palette) {
|
||||||
|
uint8_t* p = &palette[paletteIndex * 3];
|
||||||
|
return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||||
|
}
|
||||||
|
return paletteIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
case PNG_PIXEL_GRAY_ALPHA:
|
||||||
|
return pPixels[x * 2];
|
||||||
|
|
||||||
|
case PNG_PIXEL_TRUECOLOR_ALPHA: {
|
||||||
|
uint8_t* p = &pPixels[x * 4];
|
||||||
|
return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 128;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int pngDrawCallback(PNGDRAW* pDraw) {
|
||||||
|
if (!gConfig || !gRenderer) return 0;
|
||||||
|
|
||||||
|
int srcY = pDraw->y;
|
||||||
|
uint8_t* pPixels = pDraw->pPixels;
|
||||||
|
int pixelType = pDraw->iPixelType;
|
||||||
|
|
||||||
|
// Calculate destination Y with scaling
|
||||||
|
int dstY = (int)(srcY * gScale);
|
||||||
|
|
||||||
|
// Skip if we already rendered this destination row (multiple source rows map to same dest)
|
||||||
|
if (dstY == gLastDstY) return 1;
|
||||||
|
gLastDstY = dstY;
|
||||||
|
|
||||||
|
// Check bounds
|
||||||
|
if (dstY >= gDstHeight) return 1;
|
||||||
|
|
||||||
|
int outY = gConfig->y + dstY;
|
||||||
|
if (outY >= gScreenHeight) return 1;
|
||||||
|
|
||||||
|
// Render scaled row using nearest-neighbor sampling
|
||||||
|
for (int dstX = 0; dstX < gDstWidth; dstX++) {
|
||||||
|
int outX = gConfig->x + dstX;
|
||||||
|
if (outX >= gScreenWidth) continue;
|
||||||
|
|
||||||
|
// Map destination X back to source X
|
||||||
|
int srcX = (int)(dstX / gScale);
|
||||||
|
if (srcX >= gSrcWidth) srcX = gSrcWidth - 1;
|
||||||
|
|
||||||
|
uint8_t gray = getGrayFromPixel(pPixels, srcX, pixelType, pDraw->pPalette);
|
||||||
|
|
||||||
|
uint8_t ditheredGray;
|
||||||
|
if (gConfig->useDithering) {
|
||||||
|
ditheredGray = applyBayerDither4Level(gray, outX, outY);
|
||||||
|
} else {
|
||||||
|
ditheredGray = gray / 85;
|
||||||
|
if (ditheredGray > 3) ditheredGray = 3;
|
||||||
|
}
|
||||||
|
drawPixelWithRenderMode(gRenderer, outX, outY, ditheredGray);
|
||||||
|
cacheSetPixel(outX, outY, ditheredGray);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
||||||
|
const RenderConfig& config) {
|
||||||
|
Serial.printf("[%lu] [PNG] Decoding PNG: %s\n", millis(), imagePath.c_str());
|
||||||
|
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForRead("PNG", imagePath, file)) {
|
||||||
|
Serial.printf("[%lu] [PNG] Failed to open file: %s\n", millis(), imagePath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
gRenderer = &renderer;
|
||||||
|
gConfig = &config;
|
||||||
|
gScreenWidth = renderer.getScreenWidth();
|
||||||
|
gScreenHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
|
int rc = png.open(imagePath.c_str(), pngOpen, pngClose, pngRead, pngSeek, pngDrawCallback);
|
||||||
|
if (rc != PNG_SUCCESS) {
|
||||||
|
Serial.printf("[%lu] [PNG] Failed to open PNG: %d\n", millis(), rc);
|
||||||
|
file.close();
|
||||||
|
gRenderer = nullptr;
|
||||||
|
gConfig = nullptr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateImageDimensions(png.getWidth(), png.getHeight(), "PNG")) {
|
||||||
|
png.close();
|
||||||
|
file.close();
|
||||||
|
gRenderer = nullptr;
|
||||||
|
gConfig = nullptr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate scale factor to fit within maxWidth x maxHeight
|
||||||
|
gSrcWidth = png.getWidth();
|
||||||
|
gSrcHeight = png.getHeight();
|
||||||
|
float scaleX = (float)config.maxWidth / gSrcWidth;
|
||||||
|
float scaleY = (float)config.maxHeight / gSrcHeight;
|
||||||
|
gScale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||||
|
if (gScale > 1.0f) gScale = 1.0f; // Don't upscale
|
||||||
|
|
||||||
|
gDstWidth = (int)(gSrcWidth * gScale);
|
||||||
|
gDstHeight = (int)(gSrcHeight * gScale);
|
||||||
|
gLastDstY = -1; // Reset row tracking
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [PNG] PNG %dx%d -> %dx%d (scale %.2f), bpp: %d\n", millis(), gSrcWidth, gSrcHeight, gDstWidth,
|
||||||
|
gDstHeight, gScale, png.getBpp());
|
||||||
|
|
||||||
|
if (png.getBpp() != 8) {
|
||||||
|
warnUnsupportedFeature("bit depth (" + std::to_string(png.getBpp()) + "bpp)", imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (png.hasAlpha()) {
|
||||||
|
warnUnsupportedFeature("alpha channel", imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate cache buffer using SCALED dimensions
|
||||||
|
bool caching = !config.cachePath.empty();
|
||||||
|
if (caching) {
|
||||||
|
gCacheWidth = gDstWidth;
|
||||||
|
gCacheHeight = gDstHeight;
|
||||||
|
gCacheBytesPerRow = (gCacheWidth + 3) / 4;
|
||||||
|
gCacheOriginX = config.x;
|
||||||
|
gCacheOriginY = config.y;
|
||||||
|
size_t bufferSize = gCacheBytesPerRow * gCacheHeight;
|
||||||
|
gCacheBuffer = (uint8_t*)malloc(bufferSize);
|
||||||
|
if (gCacheBuffer) {
|
||||||
|
memset(gCacheBuffer, 0, bufferSize);
|
||||||
|
Serial.printf("[%lu] [PNG] Allocated cache buffer: %d bytes for %dx%d\n", millis(), bufferSize, gCacheWidth,
|
||||||
|
gCacheHeight);
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [PNG] Failed to allocate cache buffer, continuing without caching\n", millis());
|
||||||
|
caching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = png.decode(nullptr, 0);
|
||||||
|
if (rc != PNG_SUCCESS) {
|
||||||
|
Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc);
|
||||||
|
png.close();
|
||||||
|
file.close();
|
||||||
|
gRenderer = nullptr;
|
||||||
|
gConfig = nullptr;
|
||||||
|
if (gCacheBuffer) {
|
||||||
|
free(gCacheBuffer);
|
||||||
|
gCacheBuffer = nullptr;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
png.close();
|
||||||
|
file.close();
|
||||||
|
Serial.printf("[%lu] [PNG] PNG decoding complete\n", millis());
|
||||||
|
|
||||||
|
// Write cache file if caching was enabled and buffer was allocated
|
||||||
|
if (caching && gCacheBuffer) {
|
||||||
|
FsFile cacheFile;
|
||||||
|
if (SdMan.openFileForWrite("IMG", config.cachePath, cacheFile)) {
|
||||||
|
uint16_t w = gCacheWidth;
|
||||||
|
uint16_t h = gCacheHeight;
|
||||||
|
cacheFile.write(&w, 2);
|
||||||
|
cacheFile.write(&h, 2);
|
||||||
|
cacheFile.write(gCacheBuffer, gCacheBytesPerRow * gCacheHeight);
|
||||||
|
cacheFile.close();
|
||||||
|
Serial.printf("[%lu] [PNG] Cache written: %s (%dx%d, %d bytes)\n", millis(), config.cachePath.c_str(),
|
||||||
|
gCacheWidth, gCacheHeight, 4 + gCacheBytesPerRow * gCacheHeight);
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [PNG] Failed to open cache file for writing: %s\n", millis(), config.cachePath.c_str());
|
||||||
|
}
|
||||||
|
free(gCacheBuffer);
|
||||||
|
gCacheBuffer = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
gRenderer = nullptr;
|
||||||
|
gConfig = nullptr;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PngToFramebufferConverter::supportsFormat(const std::string& extension) const {
|
||||||
|
std::string ext = extension;
|
||||||
|
for (auto& c : ext) {
|
||||||
|
c = tolower(c);
|
||||||
|
}
|
||||||
|
return (ext == ".png");
|
||||||
|
}
|
||||||
19
lib/Epub/Epub/converters/PngToFramebufferConverter.h
Normal file
19
lib/Epub/Epub/converters/PngToFramebufferConverter.h
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <PNGdec.h>
|
||||||
|
|
||||||
|
#include "ImageToFramebufferDecoder.h"
|
||||||
|
|
||||||
|
class PngToFramebufferConverter final : public ImageToFramebufferDecoder {
|
||||||
|
public:
|
||||||
|
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
|
||||||
|
|
||||||
|
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
|
||||||
|
|
||||||
|
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
|
||||||
|
return getDimensionsStatic(imagePath, dims);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool supportsFormat(const std::string& extension) const override;
|
||||||
|
const char* getFormatName() const override { return "PNG"; }
|
||||||
|
};
|
||||||
@ -5,7 +5,10 @@
|
|||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
#include <expat.h>
|
#include <expat.h>
|
||||||
|
|
||||||
|
#include "../../Epub.h"
|
||||||
#include "../Page.h"
|
#include "../Page.h"
|
||||||
|
#include "../converters/ImageDecoderFactory.h"
|
||||||
|
#include "../converters/ImageToFramebufferDecoder.h"
|
||||||
|
|
||||||
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
|
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
|
||||||
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
|
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
|
||||||
@ -96,30 +99,143 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
|
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
|
||||||
// TODO: Start processing image tags
|
std::string src;
|
||||||
std::string alt = "[Image]";
|
std::string alt;
|
||||||
if (atts != nullptr) {
|
if (atts != nullptr) {
|
||||||
for (int i = 0; atts[i]; i += 2) {
|
for (int i = 0; atts[i]; i += 2) {
|
||||||
if (strcmp(atts[i], "alt") == 0) {
|
if (strcmp(atts[i], "src") == 0) {
|
||||||
if (strlen(atts[i + 1]) > 0) {
|
src = atts[i + 1];
|
||||||
alt = "[Image: " + std::string(atts[i + 1]) + "]";
|
} else if (strcmp(atts[i], "alt") == 0) {
|
||||||
}
|
alt = atts[i + 1];
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!src.empty()) {
|
||||||
|
Serial.printf("[%lu] [EHP] Found image: src=%s\n", millis(), src.c_str());
|
||||||
|
|
||||||
|
// Get the spine item's href to resolve the relative path
|
||||||
|
size_t lastUnderscore = self->filepath.rfind('_');
|
||||||
|
if (lastUnderscore != std::string::npos && lastUnderscore > 0) {
|
||||||
|
std::string indexStr = self->filepath.substr(lastUnderscore + 1);
|
||||||
|
indexStr.resize(indexStr.find('.'));
|
||||||
|
int spineIndex = atoi(indexStr.c_str());
|
||||||
|
|
||||||
|
const auto& spineItem = self->epub->getSpineItem(spineIndex);
|
||||||
|
std::string htmlHref = spineItem.href;
|
||||||
|
size_t lastSlash = htmlHref.find_last_of('/');
|
||||||
|
std::string htmlDir = (lastSlash != std::string::npos) ? htmlHref.substr(0, lastSlash + 1) : "";
|
||||||
|
|
||||||
|
// Resolve the image path relative to the HTML file
|
||||||
|
std::string imageHref = src;
|
||||||
|
while (imageHref.find("../") == 0) {
|
||||||
|
imageHref = imageHref.substr(3);
|
||||||
|
if (!htmlDir.empty()) {
|
||||||
|
size_t dirSlash = htmlDir.find_last_of('/', htmlDir.length() - 2);
|
||||||
|
htmlDir = (dirSlash != std::string::npos) ? htmlDir.substr(0, dirSlash + 1) : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::string resolvedPath = htmlDir + imageHref;
|
||||||
|
|
||||||
|
// Create a unique filename for the cached image
|
||||||
|
std::string ext;
|
||||||
|
size_t extPos = resolvedPath.rfind('.');
|
||||||
|
if (extPos != std::string::npos) {
|
||||||
|
ext = resolvedPath.substr(extPos);
|
||||||
|
}
|
||||||
|
std::string cachedImagePath = self->epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_" +
|
||||||
|
std::to_string(self->imageCounter++) + ext;
|
||||||
|
|
||||||
|
// Extract image to cache file
|
||||||
|
FsFile cachedImageFile;
|
||||||
|
bool extractSuccess = false;
|
||||||
|
if (SdMan.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
|
||||||
|
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
|
||||||
|
cachedImageFile.flush();
|
||||||
|
cachedImageFile.close();
|
||||||
|
delay(50); // Give SD card time to sync
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractSuccess) {
|
||||||
|
// Get image dimensions
|
||||||
|
ImageDimensions dims = {0, 0};
|
||||||
|
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
|
||||||
|
if (decoder && decoder->getDimensions(cachedImagePath, dims)) {
|
||||||
|
Serial.printf("[%lu] [EHP] Image dimensions: %dx%d\n", millis(), dims.width, dims.height);
|
||||||
|
|
||||||
|
// Scale to fit viewport while maintaining aspect ratio
|
||||||
|
int maxWidth = self->viewportWidth;
|
||||||
|
int maxHeight = self->viewportHeight;
|
||||||
|
float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f;
|
||||||
|
float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f;
|
||||||
|
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||||
|
if (scale > 1.0f) scale = 1.0f;
|
||||||
|
|
||||||
|
int displayWidth = (int)(dims.width * scale);
|
||||||
|
int displayHeight = (int)(dims.height * scale);
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [EHP] Display size: %dx%d (scale %.2f)\n", millis(), displayWidth, displayHeight,
|
||||||
|
scale);
|
||||||
|
|
||||||
|
// Create page for image
|
||||||
|
if (self->currentPage && !self->currentPage->elements.empty()) {
|
||||||
|
self->completePageFn(std::move(self->currentPage));
|
||||||
|
self->currentPage.reset(new Page());
|
||||||
|
if (!self->currentPage) {
|
||||||
|
Serial.printf("[%lu] [EHP] Failed to create new page\n", millis());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->currentPageNextY = 0;
|
||||||
|
} else if (!self->currentPage) {
|
||||||
|
self->currentPage.reset(new Page());
|
||||||
|
if (!self->currentPage) {
|
||||||
|
Serial.printf("[%lu] [EHP] Failed to create initial page\n", millis());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->currentPageNextY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ImageBlock and add to page
|
||||||
|
auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight);
|
||||||
|
if (!imageBlock) {
|
||||||
|
Serial.printf("[%lu] [EHP] Failed to create ImageBlock\n", millis());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int xPos = (self->viewportWidth - displayWidth) / 2;
|
||||||
|
auto pageImage = std::make_shared<PageImage>(imageBlock, xPos, self->currentPageNextY);
|
||||||
|
if (!pageImage) {
|
||||||
|
Serial.printf("[%lu] [EHP] Failed to create PageImage\n", millis());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->currentPage->elements.push_back(pageImage);
|
||||||
|
self->currentPageNextY += displayHeight;
|
||||||
|
|
||||||
|
self->depth += 1;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [EHP] Failed to get image dimensions\n", millis());
|
||||||
|
SdMan.remove(cachedImagePath.c_str());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [EHP] Failed to extract image\n", millis());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to alt text if image processing fails
|
||||||
|
if (!alt.empty()) {
|
||||||
|
alt = "[Image: " + alt + "]";
|
||||||
|
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
|
||||||
|
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
||||||
|
self->depth += 1;
|
||||||
|
self->characterData(userData, alt.c_str(), alt.length());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No alt text, skip
|
||||||
|
self->skipUntilDepth = self->depth;
|
||||||
|
self->depth += 1;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str());
|
|
||||||
|
|
||||||
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
|
|
||||||
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
|
|
||||||
// Advance depth before processing character data (like you would for a element with text)
|
|
||||||
self->depth += 1;
|
|
||||||
self->characterData(userData, alt.c_str(), alt.length());
|
|
||||||
|
|
||||||
// Skip table contents (skip until parent as we pre-advanced depth above)
|
|
||||||
self->skipUntilDepth = self->depth - 1;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) {
|
if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) {
|
||||||
|
|||||||
@ -7,14 +7,17 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
#include "../ParsedText.h"
|
#include "../ParsedText.h"
|
||||||
|
#include "../blocks/ImageBlock.h"
|
||||||
#include "../blocks/TextBlock.h"
|
#include "../blocks/TextBlock.h"
|
||||||
|
|
||||||
class Page;
|
class Page;
|
||||||
class GfxRenderer;
|
class GfxRenderer;
|
||||||
|
class Epub;
|
||||||
|
|
||||||
#define MAX_WORD_SIZE 200
|
#define MAX_WORD_SIZE 200
|
||||||
|
|
||||||
class ChapterHtmlSlimParser {
|
class ChapterHtmlSlimParser {
|
||||||
|
std::shared_ptr<Epub> epub;
|
||||||
const std::string& filepath;
|
const std::string& filepath;
|
||||||
GfxRenderer& renderer;
|
GfxRenderer& renderer;
|
||||||
std::function<void(std::unique_ptr<Page>)> completePageFn;
|
std::function<void(std::unique_ptr<Page>)> completePageFn;
|
||||||
@ -37,6 +40,7 @@ class ChapterHtmlSlimParser {
|
|||||||
uint16_t viewportWidth;
|
uint16_t viewportWidth;
|
||||||
uint16_t viewportHeight;
|
uint16_t viewportHeight;
|
||||||
bool hyphenationEnabled;
|
bool hyphenationEnabled;
|
||||||
|
int imageCounter = 0;
|
||||||
|
|
||||||
void startNewTextBlock(TextBlock::Style style);
|
void startNewTextBlock(TextBlock::Style style);
|
||||||
void flushPartWordBuffer();
|
void flushPartWordBuffer();
|
||||||
@ -47,13 +51,14 @@ class ChapterHtmlSlimParser {
|
|||||||
static void XMLCALL endElement(void* userData, const XML_Char* name);
|
static void XMLCALL endElement(void* userData, const XML_Char* name);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
|
explicit ChapterHtmlSlimParser(std::shared_ptr<Epub> epub, const std::string& filepath, GfxRenderer& renderer,
|
||||||
const float lineCompression, const bool extraParagraphSpacing,
|
const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||||
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
||||||
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
|
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
|
||||||
const std::function<void()>& popupFn = nullptr)
|
const std::function<void()>& popupFn = nullptr)
|
||||||
: filepath(filepath),
|
: epub(epub),
|
||||||
|
filepath(filepath),
|
||||||
renderer(renderer),
|
renderer(renderer),
|
||||||
fontId(fontId),
|
fontId(fontId),
|
||||||
lineCompression(lineCompression),
|
lineCompression(lineCompression),
|
||||||
|
|||||||
@ -96,6 +96,7 @@ class GfxRenderer {
|
|||||||
public:
|
public:
|
||||||
// Grayscale functions
|
// Grayscale functions
|
||||||
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
||||||
|
RenderMode getRenderMode() const { return renderMode; }
|
||||||
void copyGrayscaleLsbBuffers() const;
|
void copyGrayscaleLsbBuffers() const;
|
||||||
void copyGrayscaleMsbBuffers() const;
|
void copyGrayscaleMsbBuffers() const;
|
||||||
void displayGrayBuffer() const;
|
void displayGrayBuffer() const;
|
||||||
|
|||||||
@ -30,6 +30,9 @@ build_flags =
|
|||||||
-std=c++2a
|
-std=c++2a
|
||||||
# Enable UTF-8 long file names in SdFat
|
# Enable UTF-8 long file names in SdFat
|
||||||
-DUSE_UTF8_LONG_NAMES=1
|
-DUSE_UTF8_LONG_NAMES=1
|
||||||
|
# Increase PNG scanline buffer to support up to 800px wide images
|
||||||
|
# Default is (320*4+1)*2=2562, we need more for larger images
|
||||||
|
-DPNG_MAX_BUFFERED_PIXELS=6402
|
||||||
|
|
||||||
; Board configuration
|
; Board configuration
|
||||||
board_build.flash_mode = dio
|
board_build.flash_mode = dio
|
||||||
@ -47,6 +50,7 @@ lib_deps =
|
|||||||
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
|
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
|
||||||
bblanchon/ArduinoJson @ 7.4.2
|
bblanchon/ArduinoJson @ 7.4.2
|
||||||
ricmoo/QRCode @ 0.0.1
|
ricmoo/QRCode @ 0.0.1
|
||||||
|
bitbank2/PNGdec @ ^1.0.0
|
||||||
links2004/WebSockets @ 2.7.3
|
links2004/WebSockets @ 2.7.3
|
||||||
|
|
||||||
[env:default]
|
[env:default]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user