feat: Add EPUB embedded image support (JPEG/PNG)
Cherry-pick merge from pablohc/crosspoint-reader@2d8cbcf, based on upstream PR #556 by martinbrook with pablohc's refresh optimization. - Add JPEG decoder (picojpeg) and PNG decoder (PNGdec) with 4-level grayscale Bayer dithering for e-ink display - Add pixel caching system (.pxc files) for fast image re-rendering - Integrate image extraction from EPUB HTML parser (<img> tag support) - Add ImageBlock/PageImage types with serialization support - Add image-aware refresh optimization (double FAST_REFRESH technique) - Add experimental displayWindow() partial refresh support - Bump section cache version 12->13 to invalidate stale caches - Resolve TAG_PageImage=3 to avoid conflict with mod's TAG_PageTableRow=2 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,6 +34,33 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PageImage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PageTableRow
|
// PageTableRow
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -183,6 +210,9 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
page->elements.push_back(std::move(tr));
|
page->elements.push_back(std::move(tr));
|
||||||
|
} else if (tag == TAG_PageImage) {
|
||||||
|
auto pi = PageImage::deserialize(file);
|
||||||
|
page->elements.push_back(std::move(pi));
|
||||||
} else {
|
} else {
|
||||||
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
|
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
@@ -191,3 +221,50 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
|
|||||||
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Page::getImageBoundingBox(int& outX, int& outY, int& outWidth, int& outHeight) const {
|
||||||
|
bool firstImage = true;
|
||||||
|
for (const auto& el : elements) {
|
||||||
|
if (el->getTag() == TAG_PageImage) {
|
||||||
|
PageImage* pi = static_cast<PageImage*>(el.get());
|
||||||
|
ImageBlock* ib = pi->getImageBlock();
|
||||||
|
|
||||||
|
if (firstImage) {
|
||||||
|
// Initialize with first image bounds
|
||||||
|
outX = pi->xPos;
|
||||||
|
outY = pi->yPos;
|
||||||
|
outWidth = ib->getWidth();
|
||||||
|
outHeight = ib->getHeight();
|
||||||
|
firstImage = false;
|
||||||
|
} else {
|
||||||
|
// Expand bounding box to include this image
|
||||||
|
int imgX = pi->xPos;
|
||||||
|
int imgY = pi->yPos;
|
||||||
|
int imgW = ib->getWidth();
|
||||||
|
int imgH = ib->getHeight();
|
||||||
|
|
||||||
|
// Expand right boundary
|
||||||
|
if (imgX + imgW > outX + outWidth) {
|
||||||
|
outWidth = (imgX + imgW) - outX;
|
||||||
|
}
|
||||||
|
// Expand left boundary
|
||||||
|
if (imgX < outX) {
|
||||||
|
int oldRight = outX + outWidth;
|
||||||
|
outX = imgX;
|
||||||
|
outWidth = oldRight - outX;
|
||||||
|
}
|
||||||
|
// Expand bottom boundary
|
||||||
|
if (imgY + imgH > outY + outHeight) {
|
||||||
|
outHeight = (imgY + imgH) - outY;
|
||||||
|
}
|
||||||
|
// Expand top boundary
|
||||||
|
if (imgY < outY) {
|
||||||
|
int oldBottom = outY + outHeight;
|
||||||
|
outY = imgY;
|
||||||
|
outHeight = oldBottom - outY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !firstImage; // Return true if at least one image was found
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#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_PageTableRow = 2,
|
TAG_PageTableRow = 2,
|
||||||
|
TAG_PageImage = 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
// represents something that has been added to a page
|
// represents something that has been added to a page
|
||||||
@@ -67,6 +70,22 @@ class PageTableRow final : public PageElement {
|
|||||||
static std::unique_ptr<PageTableRow> deserialize(FsFile& file);
|
static std::unique_ptr<PageTableRow> deserialize(FsFile& file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// An image element on a page
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Helper to get image block dimensions (needed for bounding box calculation)
|
||||||
|
ImageBlock* getImageBlock() const { return imageBlock.get(); }
|
||||||
|
};
|
||||||
|
|
||||||
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
|
||||||
@@ -74,4 +93,15 @@ class Page {
|
|||||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
||||||
bool serialize(FsFile& file) const;
|
bool serialize(FsFile& file) const;
|
||||||
static std::unique_ptr<Page> deserialize(FsFile& file);
|
static std::unique_ptr<Page> deserialize(FsFile& file);
|
||||||
|
|
||||||
|
// Check if page contains any images (used to force full refresh)
|
||||||
|
bool hasImages() const {
|
||||||
|
return std::any_of(elements.begin(), elements.end(),
|
||||||
|
[](const std::shared_ptr<PageElement>& el) { return el->getTag() == TAG_PageImage; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the bounding box of all images on this page.
|
||||||
|
// Returns true if page has images and fills out the bounding box coordinates.
|
||||||
|
// If no images, returns false.
|
||||||
|
bool getImageBoundingBox(int& outX, int& outY, int& outWidth, int& outHeight) const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
#include "parsers/ChapterHtmlSlimParser.h"
|
#include "parsers/ChapterHtmlSlimParser.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SECTION_FILE_VERSION = 12;
|
constexpr uint8_t SECTION_FILE_VERSION = 13;
|
||||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||||
sizeof(uint32_t);
|
sizeof(uint32_t);
|
||||||
@@ -181,11 +181,16 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
viewportHeight, hyphenationEnabled, embeddedStyle);
|
viewportHeight, hyphenationEnabled, embeddedStyle);
|
||||||
std::vector<uint32_t> lut = {};
|
std::vector<uint32_t> lut = {};
|
||||||
|
|
||||||
|
// Derive the content base directory and image cache path prefix for the parser
|
||||||
|
size_t lastSlash = localPath.find_last_of('/');
|
||||||
|
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
||||||
|
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
||||||
|
|
||||||
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))); },
|
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
||||||
embeddedStyle, popupFn, embeddedStyle ? epub->getCssParser() : nullptr);
|
embeddedStyle, contentBase, imageBasePath, popupFn, embeddedStyle ? epub->getCssParser() : nullptr);
|
||||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||||
success = visitor.parseAndBuildPages();
|
success = visitor.parseAndBuildPages();
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ typedef enum { TEXT_BLOCK, IMAGE_BLOCK } BlockType;
|
|||||||
class Block {
|
class Block {
|
||||||
public:
|
public:
|
||||||
virtual ~Block() = default;
|
virtual ~Block() = default;
|
||||||
virtual void layout(GfxRenderer& renderer) = 0;
|
|
||||||
virtual BlockType getType() = 0;
|
virtual BlockType getType() = 0;
|
||||||
virtual bool isEmpty() = 0;
|
virtual bool isEmpty() = 0;
|
||||||
virtual void finish() {}
|
virtual void finish() {}
|
||||||
|
|||||||
175
lib/Epub/Epub/blocks/ImageBlock.cpp
Normal file
175
lib/Epub/Epub/blocks/ImageBlock.cpp
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
#include "ImageBlock.h"
|
||||||
|
|
||||||
|
#include <FsHelpers.h>
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <Serialization.h>
|
||||||
|
|
||||||
|
#include "../converters/DitherUtils.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 { return Storage.exists(imagePath.c_str()); }
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
||||||
|
int expectedHeight) {
|
||||||
|
FsFile cacheFile;
|
||||||
|
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t cachedWidth, cachedHeight;
|
||||||
|
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify dimensions are close (allow 1 pixel tolerance for rounding differences)
|
||||||
|
int widthDiff = abs(cachedWidth - expectedWidth);
|
||||||
|
int heightDiff = abs(cachedHeight - expectedHeight);
|
||||||
|
if (widthDiff > 1 || heightDiff > 1) {
|
||||||
|
Serial.printf("[%lu] [IMG] Cache dimension mismatch: %dx%d vs %dx%d\n", millis(), cachedWidth, cachedHeight,
|
||||||
|
expectedWidth, expectedHeight);
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached dimensions for rendering (they're the actual decoded size)
|
||||||
|
expectedWidth = cachedWidth;
|
||||||
|
expectedHeight = cachedHeight;
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [IMG] Loading from cache: %s (%dx%d)\n", millis(), cachePath.c_str(), cachedWidth, cachedHeight);
|
||||||
|
|
||||||
|
// Read and render row by row to minimize memory usage
|
||||||
|
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||||
|
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
|
||||||
|
if (!rowBuffer) {
|
||||||
|
Serial.printf("[%lu] [IMG] Failed to allocate row buffer\n", millis());
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int row = 0; row < cachedHeight; row++) {
|
||||||
|
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
|
||||||
|
Serial.printf("[%lu] [IMG] Cache read error at row %d\n", millis(), row);
|
||||||
|
free(rowBuffer);
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int destY = y + row;
|
||||||
|
for (int col = 0; col < cachedWidth; col++) {
|
||||||
|
int byteIdx = col / 4;
|
||||||
|
int bitShift = 6 - (col % 4) * 2; // MSB first within byte
|
||||||
|
uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
|
||||||
|
|
||||||
|
drawPixelWithRenderMode(renderer, x + col, destY, pixelValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
free(rowBuffer);
|
||||||
|
cacheFile.close();
|
||||||
|
Serial.printf("[%lu] [IMG] Cache render complete\n", millis());
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 (!Storage.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.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches
|
||||||
|
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));
|
||||||
|
}
|
||||||
31
lib/Epub/Epub/blocks/ImageBlock.h
Normal file
31
lib/Epub/Epub/blocks/ImageBlock.h
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#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;
|
||||||
|
|
||||||
|
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,7 +31,6 @@ class TextBlock final : public Block {
|
|||||||
const std::vector<uint16_t>& getWordXpos() const { return wordXpos; }
|
const std::vector<uint16_t>& getWordXpos() const { return wordXpos; }
|
||||||
const std::vector<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
|
const std::vector<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
|
||||||
bool isEmpty() override { return words.empty(); }
|
bool isEmpty() override { return words.empty(); }
|
||||||
void layout(GfxRenderer& renderer) override {};
|
|
||||||
// given a renderer works out where to break the words into lines
|
// given a renderer works out where to break the words into lines
|
||||||
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
||||||
BlockType getType() override { return TEXT_BLOCK; }
|
BlockType getType() override { return TEXT_BLOCK; }
|
||||||
|
|||||||
40
lib/Epub/Epub/converters/DitherUtils.h
Normal file
40
lib/Epub/Epub/converters/DitherUtils.h
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// 4x4 Bayer matrix for ordered dithering
|
||||||
|
inline 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
|
||||||
|
inline 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
|
||||||
|
inline 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/Epub/Epub/converters/ImageDecoderFactory.cpp
Normal file
42
lib/Epub/Epub/converters/ImageDecoderFactory.cpp
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#include "ImageDecoderFactory.h"
|
||||||
|
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "JpegToFramebufferConverter.h"
|
||||||
|
#include "PngToFramebufferConverter.h"
|
||||||
|
|
||||||
|
std::unique_ptr<JpegToFramebufferConverter> ImageDecoderFactory::jpegDecoder = nullptr;
|
||||||
|
std::unique_ptr<PngToFramebufferConverter> ImageDecoderFactory::pngDecoder = nullptr;
|
||||||
|
|
||||||
|
ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& imagePath) {
|
||||||
|
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 (JpegToFramebufferConverter::supportsFormat(ext)) {
|
||||||
|
if (!jpegDecoder) {
|
||||||
|
jpegDecoder.reset(new JpegToFramebufferConverter());
|
||||||
|
}
|
||||||
|
return jpegDecoder.get();
|
||||||
|
} else if (PngToFramebufferConverter::supportsFormat(ext)) {
|
||||||
|
if (!pngDecoder) {
|
||||||
|
pngDecoder.reset(new PngToFramebufferConverter());
|
||||||
|
}
|
||||||
|
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) { return getDecoder(imagePath) != nullptr; }
|
||||||
20
lib/Epub/Epub/converters/ImageDecoderFactory.h
Normal file
20
lib/Epub/Epub/converters/ImageDecoderFactory.h
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ImageToFramebufferDecoder.h"
|
||||||
|
|
||||||
|
class JpegToFramebufferConverter;
|
||||||
|
class PngToFramebufferConverter;
|
||||||
|
|
||||||
|
class ImageDecoderFactory {
|
||||||
|
public:
|
||||||
|
// Returns non-owning pointer - factory owns the decoder lifetime
|
||||||
|
static ImageToFramebufferDecoder* getDecoder(const std::string& imagePath);
|
||||||
|
static bool isFormatSupported(const std::string& imagePath);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::unique_ptr<JpegToFramebufferConverter> jpegDecoder;
|
||||||
|
static std::unique_ptr<PngToFramebufferConverter> pngDecoder;
|
||||||
|
};
|
||||||
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 * 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);
|
||||||
|
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());
|
||||||
|
}
|
||||||
40
lib/Epub/Epub/converters/ImageToFramebufferDecoder.h
Normal file
40
lib/Epub/Epub/converters/ImageToFramebufferDecoder.h
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#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;
|
||||||
|
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
|
||||||
|
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 const char* getFormatName() const = 0;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// Size validation helpers
|
||||||
|
static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536
|
||||||
|
|
||||||
|
bool validateImageDimensions(int width, int height, const std::string& format);
|
||||||
|
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
|
||||||
|
};
|
||||||
298
lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp
Normal file
298
lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
#include "JpegToFramebufferConverter.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <SdFat.h>
|
||||||
|
#include <picojpeg.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "DitherUtils.h"
|
||||||
|
#include "PixelCache.h"
|
||||||
|
|
||||||
|
struct JpegContext {
|
||||||
|
FsFile& file;
|
||||||
|
uint8_t buffer[512];
|
||||||
|
size_t bufferPos;
|
||||||
|
size_t bufferFilled;
|
||||||
|
JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
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());
|
||||||
|
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 (!Storage.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 output dimensions
|
||||||
|
int destWidth, destHeight;
|
||||||
|
float scale;
|
||||||
|
|
||||||
|
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
|
||||||
|
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
|
||||||
|
destWidth = config.maxWidth;
|
||||||
|
destHeight = config.maxHeight;
|
||||||
|
scale = (float)destWidth / imageInfo.m_width;
|
||||||
|
} else {
|
||||||
|
// 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;
|
||||||
|
scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||||
|
if (scale > 1.0f) scale = 1.0f;
|
||||||
|
|
||||||
|
destWidth = (int)(imageInfo.m_width * scale);
|
||||||
|
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) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool supportsFormat(const std::string& extension);
|
||||||
|
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);
|
||||||
|
};
|
||||||
85
lib/Epub/Epub/converters/PixelCache.h
Normal file
85
lib/Epub/Epub/converters/PixelCache.h
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <SdFat.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// Cache buffer for storing 2-bit pixels (4 levels) during decode.
|
||||||
|
// Packs 4 pixels per byte, MSB first.
|
||||||
|
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) {}
|
||||||
|
PixelCache(const PixelCache&) = delete;
|
||||||
|
PixelCache& operator=(const PixelCache&) = delete;
|
||||||
|
|
||||||
|
static constexpr size_t MAX_CACHE_BYTES = 256 * 1024; // 256KB limit for embedded targets
|
||||||
|
|
||||||
|
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 = (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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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 (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) {
|
||||||
|
Serial.printf("[%lu] [IMG] 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] [IMG] 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
364
lib/Epub/Epub/converters/PngToFramebufferConverter.cpp
Normal file
364
lib/Epub/Epub/converters/PngToFramebufferConverter.cpp
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
#include "PngToFramebufferConverter.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
#include <PNGdec.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <SdFat.h>
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
|
#include "DitherUtils.h"
|
||||||
|
#include "PixelCache.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Context struct passed through PNGdec callbacks to avoid global mutable state.
|
||||||
|
// The draw callback receives this via pDraw->pUser (set by png.decode()).
|
||||||
|
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by pngOpen()).
|
||||||
|
struct PngContext {
|
||||||
|
GfxRenderer* renderer;
|
||||||
|
const RenderConfig* config;
|
||||||
|
int screenWidth;
|
||||||
|
int screenHeight;
|
||||||
|
|
||||||
|
// Scaling state
|
||||||
|
float scale;
|
||||||
|
int srcWidth;
|
||||||
|
int srcHeight;
|
||||||
|
int dstWidth;
|
||||||
|
int dstHeight;
|
||||||
|
int lastDstY; // Track last rendered destination Y to avoid duplicates
|
||||||
|
|
||||||
|
PixelCache cache;
|
||||||
|
bool caching;
|
||||||
|
|
||||||
|
uint8_t* grayLineBuffer;
|
||||||
|
|
||||||
|
PngContext()
|
||||||
|
: renderer(nullptr),
|
||||||
|
config(nullptr),
|
||||||
|
screenWidth(0),
|
||||||
|
screenHeight(0),
|
||||||
|
scale(1.0f),
|
||||||
|
srcWidth(0),
|
||||||
|
srcHeight(0),
|
||||||
|
dstWidth(0),
|
||||||
|
dstHeight(0),
|
||||||
|
lastDstY(-1),
|
||||||
|
caching(false),
|
||||||
|
grayLineBuffer(nullptr) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// File I/O callbacks use pFile->fHandle to access the FsFile*,
|
||||||
|
// avoiding the need for global file state.
|
||||||
|
void* pngOpenWithHandle(const char* filename, int32_t* size) {
|
||||||
|
FsFile* f = new FsFile();
|
||||||
|
if (!Storage.openFileForRead("PNG", std::string(filename), *f)) {
|
||||||
|
delete f;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
*size = f->size();
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
void pngCloseWithHandle(void* handle) {
|
||||||
|
FsFile* f = reinterpret_cast<FsFile*>(handle);
|
||||||
|
if (f) {
|
||||||
|
f->close();
|
||||||
|
delete f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t pngReadWithHandle(PNGFILE* pFile, uint8_t* pBuf, int32_t len) {
|
||||||
|
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
||||||
|
if (!f) return 0;
|
||||||
|
return f->read(pBuf, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) {
|
||||||
|
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
||||||
|
if (!f) return -1;
|
||||||
|
return f->seek(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The PNG decoder (PNGdec) is ~42 KB due to internal zlib decompression buffers.
|
||||||
|
// We heap-allocate it on demand rather than using a static instance, so this memory
|
||||||
|
// is only consumed while actually decoding/querying PNG images. This is critical on
|
||||||
|
// the ESP32-C3 where total RAM is ~320 KB.
|
||||||
|
constexpr size_t PNG_DECODER_APPROX_SIZE = 44 * 1024; // ~42 KB + overhead
|
||||||
|
constexpr size_t MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom
|
||||||
|
|
||||||
|
// Convert entire source line to grayscale with alpha blending to white background.
|
||||||
|
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
|
||||||
|
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
|
||||||
|
void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette, int hasAlpha) {
|
||||||
|
switch (pixelType) {
|
||||||
|
case PNG_PIXEL_GRAYSCALE:
|
||||||
|
memcpy(grayLine, pPixels, width);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PNG_PIXEL_TRUECOLOR:
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
uint8_t* p = &pPixels[x * 3];
|
||||||
|
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PNG_PIXEL_INDEXED:
|
||||||
|
if (palette) {
|
||||||
|
if (hasAlpha) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
uint8_t idx = pPixels[x];
|
||||||
|
uint8_t* p = &palette[idx * 3];
|
||||||
|
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||||
|
uint8_t alpha = palette[768 + idx];
|
||||||
|
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
uint8_t* p = &palette[pPixels[x] * 3];
|
||||||
|
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
memcpy(grayLine, pPixels, width);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PNG_PIXEL_GRAY_ALPHA:
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
uint8_t gray = pPixels[x * 2];
|
||||||
|
uint8_t alpha = pPixels[x * 2 + 1];
|
||||||
|
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PNG_PIXEL_TRUECOLOR_ALPHA:
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
uint8_t* p = &pPixels[x * 4];
|
||||||
|
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
||||||
|
uint8_t alpha = p[3];
|
||||||
|
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
memset(grayLine, 128, width);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int pngDrawCallback(PNGDRAW* pDraw) {
|
||||||
|
PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser);
|
||||||
|
if (!ctx || !ctx->config || !ctx->renderer || !ctx->grayLineBuffer) return 0;
|
||||||
|
|
||||||
|
int srcY = pDraw->y;
|
||||||
|
int srcWidth = ctx->srcWidth;
|
||||||
|
|
||||||
|
// Calculate destination Y with scaling
|
||||||
|
int dstY = (int)(srcY * ctx->scale);
|
||||||
|
|
||||||
|
// Skip if we already rendered this destination row (multiple source rows map to same dest)
|
||||||
|
if (dstY == ctx->lastDstY) return 1;
|
||||||
|
ctx->lastDstY = dstY;
|
||||||
|
|
||||||
|
// Check bounds
|
||||||
|
if (dstY >= ctx->dstHeight) return 1;
|
||||||
|
|
||||||
|
int outY = ctx->config->y + dstY;
|
||||||
|
if (outY >= ctx->screenHeight) return 1;
|
||||||
|
|
||||||
|
// Convert entire source line to grayscale (improves cache locality)
|
||||||
|
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->pPalette,
|
||||||
|
pDraw->iHasAlpha);
|
||||||
|
|
||||||
|
// Render scaled row using Bresenham-style integer stepping (no floating-point division)
|
||||||
|
int dstWidth = ctx->dstWidth;
|
||||||
|
int outXBase = ctx->config->x;
|
||||||
|
int screenWidth = ctx->screenWidth;
|
||||||
|
bool useDithering = ctx->config->useDithering;
|
||||||
|
bool caching = ctx->caching;
|
||||||
|
|
||||||
|
int srcX = 0;
|
||||||
|
int error = 0;
|
||||||
|
|
||||||
|
for (int dstX = 0; dstX < dstWidth; dstX++) {
|
||||||
|
int outX = outXBase + dstX;
|
||||||
|
if (outX < screenWidth) {
|
||||||
|
uint8_t gray = ctx->grayLineBuffer[srcX];
|
||||||
|
|
||||||
|
uint8_t ditheredGray;
|
||||||
|
if (useDithering) {
|
||||||
|
ditheredGray = applyBayerDither4Level(gray, outX, outY);
|
||||||
|
} else {
|
||||||
|
ditheredGray = gray / 85;
|
||||||
|
if (ditheredGray > 3) ditheredGray = 3;
|
||||||
|
}
|
||||||
|
drawPixelWithRenderMode(*ctx->renderer, outX, outY, ditheredGray);
|
||||||
|
if (caching) ctx->cache.setPixel(outX, outY, ditheredGray);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
|
||||||
|
error += srcWidth;
|
||||||
|
while (error >= dstWidth) {
|
||||||
|
error -= dstWidth;
|
||||||
|
srcX++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
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);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PNG* png = new (std::nothrow) PNG();
|
||||||
|
if (!png) {
|
||||||
|
Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder for dimensions\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
if (rc != 0) {
|
||||||
|
Serial.printf("[%lu] [PNG] Failed to open PNG for dimensions: %d\n", millis(), rc);
|
||||||
|
delete png;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.width = png->getWidth();
|
||||||
|
out.height = png->getHeight();
|
||||||
|
|
||||||
|
png->close();
|
||||||
|
delete png;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
||||||
|
const RenderConfig& config) {
|
||||||
|
Serial.printf("[%lu] [PNG] Decoding PNG: %s\n", millis(), 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);
|
||||||
|
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());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PngContext ctx;
|
||||||
|
ctx.renderer = &renderer;
|
||||||
|
ctx.config = &config;
|
||||||
|
ctx.screenWidth = renderer.getScreenWidth();
|
||||||
|
ctx.screenHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
|
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);
|
||||||
|
delete png;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateImageDimensions(png->getWidth(), png->getHeight(), "PNG")) {
|
||||||
|
png->close();
|
||||||
|
delete png;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate output dimensions
|
||||||
|
ctx.srcWidth = png->getWidth();
|
||||||
|
ctx.srcHeight = png->getHeight();
|
||||||
|
|
||||||
|
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
|
||||||
|
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
|
||||||
|
ctx.dstWidth = config.maxWidth;
|
||||||
|
ctx.dstHeight = config.maxHeight;
|
||||||
|
ctx.scale = (float)ctx.dstWidth / ctx.srcWidth;
|
||||||
|
} else {
|
||||||
|
// Calculate scale factor to fit within maxWidth/maxHeight
|
||||||
|
float scaleX = (float)config.maxWidth / ctx.srcWidth;
|
||||||
|
float scaleY = (float)config.maxHeight / ctx.srcHeight;
|
||||||
|
ctx.scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||||
|
if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale
|
||||||
|
|
||||||
|
ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale);
|
||||||
|
ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale);
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
|
||||||
|
if (png->getBpp() != 8) {
|
||||||
|
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate grayscale line buffer on demand (~3.2 KB) - freed after decode
|
||||||
|
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());
|
||||||
|
png->close();
|
||||||
|
delete png;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate cache buffer using SCALED dimensions
|
||||||
|
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());
|
||||||
|
ctx.caching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long decodeStart = millis();
|
||||||
|
rc = png->decode(&ctx, 0);
|
||||||
|
unsigned long decodeTime = millis() - decodeStart;
|
||||||
|
|
||||||
|
free(ctx.grayLineBuffer);
|
||||||
|
ctx.grayLineBuffer = nullptr;
|
||||||
|
|
||||||
|
if (rc != PNG_SUCCESS) {
|
||||||
|
Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc);
|
||||||
|
png->close();
|
||||||
|
delete png;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
png->close();
|
||||||
|
delete png;
|
||||||
|
Serial.printf("[%lu] [PNG] PNG decoding complete - render time: %lu ms\n", millis(), decodeTime);
|
||||||
|
|
||||||
|
// Write cache file if caching was enabled and buffer was allocated
|
||||||
|
if (ctx.caching) {
|
||||||
|
ctx.cache.writeToFile(config.cachePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PngToFramebufferConverter::supportsFormat(const std::string& extension) {
|
||||||
|
std::string ext = extension;
|
||||||
|
for (auto& c : ext) {
|
||||||
|
c = tolower(c);
|
||||||
|
}
|
||||||
|
return (ext == ".png");
|
||||||
|
}
|
||||||
17
lib/Epub/Epub/converters/PngToFramebufferConverter.h
Normal file
17
lib/Epub/Epub/converters/PngToFramebufferConverter.h
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#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);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool supportsFormat(const std::string& extension);
|
||||||
|
const char* getFormatName() const override { return "PNG"; }
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "ChapterHtmlSlimParser.h"
|
#include "ChapterHtmlSlimParser.h"
|
||||||
|
|
||||||
|
#include <FsHelpers.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
@@ -7,7 +8,10 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "../../Epub.h"
|
||||||
#include "../Page.h"
|
#include "../Page.h"
|
||||||
|
#include "../converters/ImageDecoderFactory.h"
|
||||||
|
#include "../converters/ImageToFramebufferDecoder.h"
|
||||||
#include "../htmlEntities.h"
|
#include "../htmlEntities.h"
|
||||||
|
|
||||||
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
|
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
|
||||||
@@ -367,30 +371,125 @@ 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()) {
|
||||||
|
LOG_DBG("EHP", "Found image: src=%s", src.c_str());
|
||||||
|
|
||||||
|
{
|
||||||
|
// Resolve the image path relative to the HTML file
|
||||||
|
std::string resolvedPath = FsHelpers::normalisePath(self->contentBase + src);
|
||||||
|
|
||||||
|
// 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->imageBasePath + std::to_string(self->imageCounter++) + ext;
|
||||||
|
|
||||||
|
// Extract image to cache file
|
||||||
|
FsFile cachedImageFile;
|
||||||
|
bool extractSuccess = false;
|
||||||
|
if (Storage.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)) {
|
||||||
|
LOG_DBG("EHP", "Image dimensions: %dx%d", 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);
|
||||||
|
|
||||||
|
LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale);
|
||||||
|
|
||||||
|
// Create page for image - only break if image won't fit remaining space
|
||||||
|
if (self->currentPage && !self->currentPage->elements.empty() &&
|
||||||
|
(self->currentPageNextY + displayHeight > self->viewportHeight)) {
|
||||||
|
self->completePageFn(std::move(self->currentPage));
|
||||||
|
self->currentPage.reset(new Page());
|
||||||
|
if (!self->currentPage) {
|
||||||
|
LOG_ERR("EHP", "Failed to create new page");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->currentPageNextY = 0;
|
||||||
|
} else if (!self->currentPage) {
|
||||||
|
self->currentPage.reset(new Page());
|
||||||
|
if (!self->currentPage) {
|
||||||
|
LOG_ERR("EHP", "Failed to create initial page");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->currentPageNextY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ImageBlock and add to page
|
||||||
|
auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight);
|
||||||
|
if (!imageBlock) {
|
||||||
|
LOG_ERR("EHP", "Failed to create ImageBlock");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int xPos = (self->viewportWidth - displayWidth) / 2;
|
||||||
|
auto pageImage = std::make_shared<PageImage>(imageBlock, xPos, self->currentPageNextY);
|
||||||
|
if (!pageImage) {
|
||||||
|
LOG_ERR("EHP", "Failed to create PageImage");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->currentPage->elements.push_back(pageImage);
|
||||||
|
self->currentPageNextY += displayHeight;
|
||||||
|
|
||||||
|
self->depth += 1;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
LOG_ERR("EHP", "Failed to get image dimensions");
|
||||||
|
Storage.remove(cachedImagePath.c_str());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOG_ERR("EHP", "Failed to extract image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to alt text if image processing fails
|
||||||
|
if (!alt.empty()) {
|
||||||
|
alt = "[Image: " + alt + "]";
|
||||||
|
self->startNewTextBlock(centeredBlockStyle);
|
||||||
|
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
||||||
|
self->depth += 1;
|
||||||
|
self->characterData(userData, alt.c_str(), alt.length());
|
||||||
|
// Skip any child content (skip until parent as we pre-advanced depth above)
|
||||||
|
self->skipUntilDepth = self->depth - 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No alt text, skip
|
||||||
|
self->skipUntilDepth = self->depth;
|
||||||
|
self->depth += 1;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_DBG("EHP", "Image alt: %s", alt.c_str());
|
|
||||||
|
|
||||||
self->startNewTextBlock(centeredBlockStyle);
|
|
||||||
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
|
|
||||||
// Advance depth before processing character data (like you would for an 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)) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include "../ParsedText.h"
|
#include "../ParsedText.h"
|
||||||
#include "../TableData.h"
|
#include "../TableData.h"
|
||||||
|
#include "../blocks/ImageBlock.h"
|
||||||
#include "../blocks/TextBlock.h"
|
#include "../blocks/TextBlock.h"
|
||||||
#include "../css/CssParser.h"
|
#include "../css/CssParser.h"
|
||||||
#include "../css/CssStyle.h"
|
#include "../css/CssStyle.h"
|
||||||
@@ -15,10 +16,12 @@
|
|||||||
class Page;
|
class Page;
|
||||||
class PageTableRow;
|
class PageTableRow;
|
||||||
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;
|
||||||
@@ -45,6 +48,9 @@ class ChapterHtmlSlimParser {
|
|||||||
bool hyphenationEnabled;
|
bool hyphenationEnabled;
|
||||||
const CssParser* cssParser;
|
const CssParser* cssParser;
|
||||||
bool embeddedStyle;
|
bool embeddedStyle;
|
||||||
|
std::string contentBase;
|
||||||
|
std::string imageBasePath;
|
||||||
|
int imageCounter = 0;
|
||||||
|
|
||||||
// Style tracking (replaces depth-based approach)
|
// Style tracking (replaces depth-based approach)
|
||||||
struct StyleStackEntry {
|
struct StyleStackEntry {
|
||||||
@@ -76,15 +82,17 @@ 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 bool embeddedStyle, const std::function<void()>& popupFn = nullptr,
|
const bool embeddedStyle, const std::string& contentBase,
|
||||||
|
const std::string& imageBasePath, const std::function<void()>& popupFn = nullptr,
|
||||||
const CssParser* cssParser = nullptr)
|
const CssParser* cssParser = nullptr)
|
||||||
|
|
||||||
: filepath(filepath),
|
: epub(epub),
|
||||||
|
filepath(filepath),
|
||||||
renderer(renderer),
|
renderer(renderer),
|
||||||
fontId(fontId),
|
fontId(fontId),
|
||||||
lineCompression(lineCompression),
|
lineCompression(lineCompression),
|
||||||
@@ -96,7 +104,9 @@ class ChapterHtmlSlimParser {
|
|||||||
completePageFn(completePageFn),
|
completePageFn(completePageFn),
|
||||||
popupFn(popupFn),
|
popupFn(popupFn),
|
||||||
cssParser(cssParser),
|
cssParser(cssParser),
|
||||||
embeddedStyle(embeddedStyle) {}
|
embeddedStyle(embeddedStyle),
|
||||||
|
contentBase(contentBase),
|
||||||
|
imageBasePath(imageBasePath) {}
|
||||||
|
|
||||||
~ChapterHtmlSlimParser() = default;
|
~ChapterHtmlSlimParser() = default;
|
||||||
bool parseAndBuildPages();
|
bool parseAndBuildPages();
|
||||||
|
|||||||
@@ -726,6 +726,23 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
|
|||||||
display.displayBuffer(refreshMode, fadingFix);
|
display.displayBuffer(refreshMode, fadingFix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EXPERIMENTAL: Display only a rectangular region with specified refresh mode
|
||||||
|
void GfxRenderer::displayWindow(int x, int y, int width, int height,
|
||||||
|
HalDisplay::RefreshMode mode) const {
|
||||||
|
LOG_DBG("GFX", "Displaying window at (%d,%d) size (%dx%d) with mode %d", x, y, width, height,
|
||||||
|
static_cast<int>(mode));
|
||||||
|
|
||||||
|
// Validate bounds
|
||||||
|
if (x < 0 || y < 0 || x + width > getScreenWidth() || y + height > getScreenHeight()) {
|
||||||
|
LOG_ERR("GFX", "Window bounds exceed display dimensions!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
display.displayWindow(static_cast<uint16_t>(x), static_cast<uint16_t>(y),
|
||||||
|
static_cast<uint16_t>(width), static_cast<uint16_t>(height), mode,
|
||||||
|
fadingFix);
|
||||||
|
}
|
||||||
|
|
||||||
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
||||||
const EpdFontFamily::Style style) const {
|
const EpdFontFamily::Style style) const {
|
||||||
if (!text || maxWidth <= 0) return "";
|
if (!text || maxWidth <= 0) return "";
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ class GfxRenderer {
|
|||||||
int getScreenHeight() const;
|
int getScreenHeight() const;
|
||||||
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
||||||
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
||||||
// void displayWindow(int x, int y, int width, int height) const;
|
void displayWindow(int x, int y, int width, int height,
|
||||||
|
HalDisplay::RefreshMode mode = HalDisplay::FAST_REFRESH) const;
|
||||||
void invertScreen() const;
|
void invertScreen() const;
|
||||||
void clearScreen(uint8_t color = 0xFF) const;
|
void clearScreen(uint8_t color = 0xFF) const;
|
||||||
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
|
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
|
||||||
@@ -120,6 +121,7 @@ class GfxRenderer {
|
|||||||
|
|
||||||
// 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;
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen)
|
|||||||
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
|
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EXPERIMENTAL: Display only a rectangular region
|
||||||
|
void HalDisplay::displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h,
|
||||||
|
HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||||
|
(void)mode; // EInkDisplay::displayWindow does not take mode yet
|
||||||
|
einkDisplay.displayWindow(x, y, w, h, turnOffScreen);
|
||||||
|
}
|
||||||
|
|
||||||
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||||
einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen);
|
einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ class HalDisplay {
|
|||||||
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||||
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||||
|
|
||||||
|
// EXPERIMENTAL: Display only a rectangular region
|
||||||
|
void displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h,
|
||||||
|
RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||||
|
|
||||||
// Power management
|
// Power management
|
||||||
void deepSleep();
|
void deepSleep();
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
501
scripts/generate_test_epub.py
Normal file
501
scripts/generate_test_epub.py
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate test EPUBs for image rendering verification.
|
||||||
|
|
||||||
|
Creates EPUBs with annotated JPEG and PNG images to verify:
|
||||||
|
- Grayscale rendering (4 levels)
|
||||||
|
- Image scaling
|
||||||
|
- Image centering
|
||||||
|
- Cache performance
|
||||||
|
- Page serialization
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
except ImportError:
|
||||||
|
print("Please install Pillow: pip install Pillow")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs"
|
||||||
|
SCREEN_WIDTH = 480
|
||||||
|
SCREEN_HEIGHT = 800
|
||||||
|
|
||||||
|
def get_font(size=20):
|
||||||
|
"""Get a font, falling back to default if needed."""
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSans.ttf", size)
|
||||||
|
except:
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
def draw_text_centered(draw, y, text, font, fill=0):
|
||||||
|
"""Draw centered text at given y position."""
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
x = (draw.im.size[0] - text_width) // 2
|
||||||
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
|
def draw_text_wrapped(draw, x, y, text, font, max_width, fill=0):
|
||||||
|
"""Draw text with word wrapping."""
|
||||||
|
words = text.split()
|
||||||
|
lines = []
|
||||||
|
current_line = []
|
||||||
|
|
||||||
|
for word in words:
|
||||||
|
test_line = ' '.join(current_line + [word])
|
||||||
|
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||||
|
if bbox[2] - bbox[0] <= max_width:
|
||||||
|
current_line.append(word)
|
||||||
|
else:
|
||||||
|
if current_line:
|
||||||
|
lines.append(' '.join(current_line))
|
||||||
|
current_line = [word]
|
||||||
|
if current_line:
|
||||||
|
lines.append(' '.join(current_line))
|
||||||
|
|
||||||
|
line_height = font.size + 4 if hasattr(font, 'size') else 20
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
draw.text((x, y + i * line_height), line, font=font, fill=fill)
|
||||||
|
|
||||||
|
return len(lines) * line_height
|
||||||
|
|
||||||
|
def create_grayscale_test_image(filename, is_png=True):
|
||||||
|
"""
|
||||||
|
Create image with 4 grayscale squares to verify 4-level rendering.
|
||||||
|
"""
|
||||||
|
width, height = 400, 600
|
||||||
|
img = Image.new('L', (width, height), 255)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
font = get_font(16)
|
||||||
|
font_small = get_font(14)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
draw_text_centered(draw, 10, "GRAYSCALE TEST", font, fill=0)
|
||||||
|
draw_text_centered(draw, 35, "Verify 4 distinct gray levels", font_small, fill=64)
|
||||||
|
|
||||||
|
# Draw 4 grayscale squares
|
||||||
|
square_size = 70
|
||||||
|
start_y = 65
|
||||||
|
gap = 10
|
||||||
|
|
||||||
|
levels = [
|
||||||
|
(0, "Level 0: BLACK"),
|
||||||
|
(96, "Level 1: DARK GRAY"),
|
||||||
|
(160, "Level 2: LIGHT GRAY"),
|
||||||
|
(255, "Level 3: WHITE"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, (gray_value, label) in enumerate(levels):
|
||||||
|
y = start_y + i * (square_size + gap + 22)
|
||||||
|
x = (width - square_size) // 2
|
||||||
|
|
||||||
|
# Draw square with border
|
||||||
|
draw.rectangle([x-2, y-2, x + square_size + 2, y + square_size + 2], fill=0)
|
||||||
|
draw.rectangle([x, y, x + square_size, y + square_size], fill=gray_value)
|
||||||
|
|
||||||
|
# Label below square
|
||||||
|
bbox = draw.textbbox((0, 0), label, font=font_small)
|
||||||
|
label_width = bbox[2] - bbox[0]
|
||||||
|
draw.text(((width - label_width) // 2, y + square_size + 5), label, font=font_small, fill=0)
|
||||||
|
|
||||||
|
# Instructions at bottom
|
||||||
|
y = height - 70
|
||||||
|
draw_text_centered(draw, y, "PASS: 4 distinct shades visible", font_small, fill=0)
|
||||||
|
draw_text_centered(draw, y + 20, "FAIL: Only black/white or", font_small, fill=64)
|
||||||
|
draw_text_centered(draw, y + 38, "muddy/indistinct grays", font_small, fill=64)
|
||||||
|
|
||||||
|
if is_png:
|
||||||
|
img.save(filename, 'PNG')
|
||||||
|
else:
|
||||||
|
img.save(filename, 'JPEG', quality=95)
|
||||||
|
|
||||||
|
def create_centering_test_image(filename, is_png=True):
|
||||||
|
"""
|
||||||
|
Create image with border markers to verify centering.
|
||||||
|
"""
|
||||||
|
width, height = 350, 400
|
||||||
|
img = Image.new('L', (width, height), 255)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
font = get_font(16)
|
||||||
|
font_small = get_font(14)
|
||||||
|
|
||||||
|
# Draw border
|
||||||
|
draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
|
||||||
|
|
||||||
|
# Corner markers
|
||||||
|
marker_size = 20
|
||||||
|
for x, y in [(0, 0), (width-marker_size, 0), (0, height-marker_size), (width-marker_size, height-marker_size)]:
|
||||||
|
draw.rectangle([x, y, x+marker_size, y+marker_size], fill=0)
|
||||||
|
|
||||||
|
# Center cross
|
||||||
|
cx, cy = width // 2, height // 2
|
||||||
|
draw.line([cx - 30, cy, cx + 30, cy], fill=0, width=2)
|
||||||
|
draw.line([cx, cy - 30, cx, cy + 30], fill=0, width=2)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
draw_text_centered(draw, 40, "CENTERING TEST", font, fill=0)
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
y = 80
|
||||||
|
draw_text_centered(draw, y, "Image should be centered", font_small, fill=0)
|
||||||
|
draw_text_centered(draw, y + 20, "horizontally on screen", font_small, fill=0)
|
||||||
|
|
||||||
|
y = 150
|
||||||
|
draw_text_centered(draw, y, "Check:", font_small, fill=0)
|
||||||
|
draw_text_centered(draw, y + 25, "- Equal margins left & right", font_small, fill=64)
|
||||||
|
draw_text_centered(draw, y + 45, "- All 4 corners visible", font_small, fill=64)
|
||||||
|
draw_text_centered(draw, y + 65, "- Border is complete rectangle", font_small, fill=64)
|
||||||
|
|
||||||
|
# Pass/fail
|
||||||
|
y = height - 80
|
||||||
|
draw_text_centered(draw, y, "PASS: Centered, all corners visible", font_small, fill=0)
|
||||||
|
draw_text_centered(draw, y + 20, "FAIL: Off-center or cropped", font_small, fill=64)
|
||||||
|
|
||||||
|
if is_png:
|
||||||
|
img.save(filename, 'PNG')
|
||||||
|
else:
|
||||||
|
img.save(filename, 'JPEG', quality=95)
|
||||||
|
|
||||||
|
def create_scaling_test_image(filename, is_png=True):
|
||||||
|
"""
|
||||||
|
Create large image to verify scaling works.
|
||||||
|
"""
|
||||||
|
width, height = 1200, 1500
|
||||||
|
img = Image.new('L', (width, height), 240)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
font = get_font(48)
|
||||||
|
font_medium = get_font(32)
|
||||||
|
font_small = get_font(24)
|
||||||
|
|
||||||
|
# Border
|
||||||
|
draw.rectangle([0, 0, width-1, height-1], outline=0, width=8)
|
||||||
|
draw.rectangle([20, 20, width-21, height-21], outline=128, width=4)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
draw_text_centered(draw, 60, "SCALING TEST", font, fill=0)
|
||||||
|
draw_text_centered(draw, 130, f"Original: {width}x{height} (larger than screen)", font_medium, fill=64)
|
||||||
|
|
||||||
|
# Grid pattern
|
||||||
|
grid_start_y = 220
|
||||||
|
grid_size = 400
|
||||||
|
cell_size = 50
|
||||||
|
|
||||||
|
draw_text_centered(draw, grid_start_y - 40, "Grid pattern (check for artifacts):", font_small, fill=0)
|
||||||
|
|
||||||
|
grid_x = (width - grid_size) // 2
|
||||||
|
for row in range(grid_size // cell_size):
|
||||||
|
for col in range(grid_size // cell_size):
|
||||||
|
x = grid_x + col * cell_size
|
||||||
|
y = grid_start_y + row * cell_size
|
||||||
|
if (row + col) % 2 == 0:
|
||||||
|
draw.rectangle([x, y, x + cell_size, y + cell_size], fill=0)
|
||||||
|
else:
|
||||||
|
draw.rectangle([x, y, x + cell_size, y + cell_size], fill=200)
|
||||||
|
|
||||||
|
# Pass/fail
|
||||||
|
y = height - 100
|
||||||
|
draw_text_centered(draw, y, "PASS: Scaled down, readable, complete", font_small, fill=0)
|
||||||
|
draw_text_centered(draw, y + 30, "FAIL: Cropped, distorted, or unreadable", font_small, fill=64)
|
||||||
|
|
||||||
|
if is_png:
|
||||||
|
img.save(filename, 'PNG')
|
||||||
|
else:
|
||||||
|
img.save(filename, 'JPEG', quality=95)
|
||||||
|
|
||||||
|
def create_cache_test_image(filename, page_num, is_png=True):
|
||||||
|
"""
|
||||||
|
Create image for cache performance testing.
|
||||||
|
"""
|
||||||
|
width, height = 400, 300
|
||||||
|
img = Image.new('L', (width, height), 255)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
font = get_font(18)
|
||||||
|
font_small = get_font(14)
|
||||||
|
font_large = get_font(36)
|
||||||
|
|
||||||
|
# Border
|
||||||
|
draw.rectangle([0, 0, width-1, height-1], outline=0, width=2)
|
||||||
|
|
||||||
|
# Page number prominent
|
||||||
|
draw_text_centered(draw, 30, f"CACHE TEST PAGE {page_num}", font, fill=0)
|
||||||
|
draw_text_centered(draw, 80, f"#{page_num}", font_large, fill=0)
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
y = 140
|
||||||
|
draw_text_centered(draw, y, "Navigate away then return", font_small, fill=64)
|
||||||
|
draw_text_centered(draw, y + 25, "Second load should be faster", font_small, fill=64)
|
||||||
|
|
||||||
|
y = 220
|
||||||
|
draw_text_centered(draw, y, "PASS: Faster reload from cache", font_small, fill=0)
|
||||||
|
draw_text_centered(draw, y + 20, "FAIL: Same slow decode each time", font_small, fill=64)
|
||||||
|
|
||||||
|
if is_png:
|
||||||
|
img.save(filename, 'PNG')
|
||||||
|
else:
|
||||||
|
img.save(filename, 'JPEG', quality=95)
|
||||||
|
|
||||||
|
def create_format_test_image(filename, format_name, is_png=True):
|
||||||
|
"""
|
||||||
|
Create simple image to verify format support.
|
||||||
|
"""
|
||||||
|
width, height = 350, 250
|
||||||
|
img = Image.new('L', (width, height), 255)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
font = get_font(20)
|
||||||
|
font_large = get_font(36)
|
||||||
|
font_small = get_font(14)
|
||||||
|
|
||||||
|
# Border
|
||||||
|
draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
|
||||||
|
|
||||||
|
# Format name
|
||||||
|
draw_text_centered(draw, 30, f"{format_name} FORMAT TEST", font, fill=0)
|
||||||
|
draw_text_centered(draw, 80, format_name, font_large, fill=0)
|
||||||
|
|
||||||
|
# Checkmark area
|
||||||
|
y = 140
|
||||||
|
draw_text_centered(draw, y, "If you can read this,", font_small, fill=64)
|
||||||
|
draw_text_centered(draw, y + 20, f"{format_name} decoding works!", font_small, fill=64)
|
||||||
|
|
||||||
|
y = height - 40
|
||||||
|
draw_text_centered(draw, y, f"PASS: {format_name} image visible", font_small, fill=0)
|
||||||
|
|
||||||
|
if is_png:
|
||||||
|
img.save(filename, 'PNG')
|
||||||
|
else:
|
||||||
|
img.save(filename, 'JPEG', quality=95)
|
||||||
|
|
||||||
|
def create_epub(epub_path, title, chapters):
|
||||||
|
"""
|
||||||
|
Create an EPUB file with the given chapters.
|
||||||
|
|
||||||
|
chapters: list of (chapter_title, html_content, images)
|
||||||
|
images: list of (image_filename, image_data)
|
||||||
|
"""
|
||||||
|
with zipfile.ZipFile(epub_path, 'w', zipfile.ZIP_DEFLATED) as epub:
|
||||||
|
# mimetype (must be first, uncompressed)
|
||||||
|
epub.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
|
||||||
|
|
||||||
|
# Container
|
||||||
|
container_xml = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||||
|
<rootfiles>
|
||||||
|
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||||
|
</rootfiles>
|
||||||
|
</container>'''
|
||||||
|
epub.writestr('META-INF/container.xml', container_xml)
|
||||||
|
|
||||||
|
# Collect all images and chapters
|
||||||
|
manifest_items = []
|
||||||
|
spine_items = []
|
||||||
|
|
||||||
|
# Add chapters and images
|
||||||
|
for i, (chapter_title, html_content, images) in enumerate(chapters):
|
||||||
|
chapter_id = f'chapter{i+1}'
|
||||||
|
chapter_file = f'chapter{i+1}.xhtml'
|
||||||
|
|
||||||
|
# Add images for this chapter
|
||||||
|
for img_filename, img_data in images:
|
||||||
|
media_type = 'image/png' if img_filename.endswith('.png') else 'image/jpeg'
|
||||||
|
manifest_items.append(f' <item id="{img_filename.replace(".", "_")}" href="images/{img_filename}" media-type="{media_type}"/>')
|
||||||
|
epub.writestr(f'OEBPS/images/{img_filename}', img_data)
|
||||||
|
|
||||||
|
# Add chapter
|
||||||
|
manifest_items.append(f' <item id="{chapter_id}" href="{chapter_file}" media-type="application/xhtml+xml"/>')
|
||||||
|
spine_items.append(f' <itemref idref="{chapter_id}"/>')
|
||||||
|
epub.writestr(f'OEBPS/{chapter_file}', html_content)
|
||||||
|
|
||||||
|
# content.opf
|
||||||
|
content_opf = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:identifier id="uid">test-epub-{title.lower().replace(" ", "-")}</dc:identifier>
|
||||||
|
<dc:title>{title}</dc:title>
|
||||||
|
<dc:language>en</dc:language>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||||||
|
{chr(10).join(manifest_items)}
|
||||||
|
</manifest>
|
||||||
|
<spine>
|
||||||
|
{chr(10).join(spine_items)}
|
||||||
|
</spine>
|
||||||
|
</package>'''
|
||||||
|
epub.writestr('OEBPS/content.opf', content_opf)
|
||||||
|
|
||||||
|
# Navigation document
|
||||||
|
nav_items = '\n'.join([f' <li><a href="chapter{i+1}.xhtml">{chapters[i][0]}</a></li>'
|
||||||
|
for i in range(len(chapters))])
|
||||||
|
nav_xhtml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||||
|
<head><title>Navigation</title></head>
|
||||||
|
<body>
|
||||||
|
<nav epub:type="toc">
|
||||||
|
<h1>Contents</h1>
|
||||||
|
<ol>
|
||||||
|
{nav_items}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</body>
|
||||||
|
</html>'''
|
||||||
|
epub.writestr('OEBPS/nav.xhtml', nav_xhtml)
|
||||||
|
|
||||||
|
def make_chapter(title, body_content):
|
||||||
|
"""Create XHTML chapter content."""
|
||||||
|
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head><title>{title}</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{body_content}
|
||||||
|
</body>
|
||||||
|
</html>'''
|
||||||
|
|
||||||
|
def main():
|
||||||
|
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tmpdir = Path(tmpdir)
|
||||||
|
|
||||||
|
print("Generating test images...")
|
||||||
|
|
||||||
|
images = {}
|
||||||
|
|
||||||
|
# JPEG tests
|
||||||
|
create_grayscale_test_image(tmpdir / 'grayscale_test.jpg', is_png=False)
|
||||||
|
create_centering_test_image(tmpdir / 'centering_test.jpg', is_png=False)
|
||||||
|
create_scaling_test_image(tmpdir / 'scaling_test.jpg', is_png=False)
|
||||||
|
create_format_test_image(tmpdir / 'jpeg_format.jpg', 'JPEG', is_png=False)
|
||||||
|
create_cache_test_image(tmpdir / 'cache_test_1.jpg', 1, is_png=False)
|
||||||
|
create_cache_test_image(tmpdir / 'cache_test_2.jpg', 2, is_png=False)
|
||||||
|
|
||||||
|
# PNG tests
|
||||||
|
create_grayscale_test_image(tmpdir / 'grayscale_test.png', is_png=True)
|
||||||
|
create_centering_test_image(tmpdir / 'centering_test.png', is_png=True)
|
||||||
|
create_scaling_test_image(tmpdir / 'scaling_test.png', is_png=True)
|
||||||
|
create_format_test_image(tmpdir / 'png_format.png', 'PNG', is_png=True)
|
||||||
|
create_cache_test_image(tmpdir / 'cache_test_1.png', 1, is_png=True)
|
||||||
|
create_cache_test_image(tmpdir / 'cache_test_2.png', 2, is_png=True)
|
||||||
|
|
||||||
|
# Read all images
|
||||||
|
for img_file in tmpdir.glob('*.*'):
|
||||||
|
images[img_file.name] = img_file.read_bytes()
|
||||||
|
|
||||||
|
print("Creating JPEG test EPUB...")
|
||||||
|
jpeg_chapters = [
|
||||||
|
("Introduction", make_chapter("JPEG Image Tests", """
|
||||||
|
<p>This EPUB tests JPEG image rendering.</p>
|
||||||
|
<p>Navigate through chapters to verify each test case.</p>
|
||||||
|
"""), []),
|
||||||
|
("1. JPEG Format", make_chapter("JPEG Format Test", """
|
||||||
|
<p>Basic JPEG decoding test.</p>
|
||||||
|
<img src="images/jpeg_format.jpg" alt="JPEG format test"/>
|
||||||
|
<p>If the image above is visible, JPEG decoding works.</p>
|
||||||
|
"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]),
|
||||||
|
("2. Grayscale", make_chapter("Grayscale Test", """
|
||||||
|
<p>Verify 4 distinct gray levels are visible.</p>
|
||||||
|
<img src="images/grayscale_test.jpg" alt="Grayscale test"/>
|
||||||
|
"""), [('grayscale_test.jpg', images['grayscale_test.jpg'])]),
|
||||||
|
("3. Centering", make_chapter("Centering Test", """
|
||||||
|
<p>Verify image is centered horizontally.</p>
|
||||||
|
<img src="images/centering_test.jpg" alt="Centering test"/>
|
||||||
|
"""), [('centering_test.jpg', images['centering_test.jpg'])]),
|
||||||
|
("4. Scaling", make_chapter("Scaling Test", """
|
||||||
|
<p>This image is 1200x1500 pixels - larger than the screen.</p>
|
||||||
|
<p>It should be scaled down to fit.</p>
|
||||||
|
<img src="images/scaling_test.jpg" alt="Scaling test"/>
|
||||||
|
"""), [('scaling_test.jpg', images['scaling_test.jpg'])]),
|
||||||
|
("5. Cache Test A", make_chapter("Cache Test - Page A", """
|
||||||
|
<p>First cache test page. Note the load time.</p>
|
||||||
|
<img src="images/cache_test_1.jpg" alt="Cache test 1"/>
|
||||||
|
<p>Navigate to next page, then come back.</p>
|
||||||
|
"""), [('cache_test_1.jpg', images['cache_test_1.jpg'])]),
|
||||||
|
("6. Cache Test B", make_chapter("Cache Test - Page B", """
|
||||||
|
<p>Second cache test page.</p>
|
||||||
|
<img src="images/cache_test_2.jpg" alt="Cache test 2"/>
|
||||||
|
<p>Navigate back to Page A - it should load faster from cache.</p>
|
||||||
|
"""), [('cache_test_2.jpg', images['cache_test_2.jpg'])]),
|
||||||
|
]
|
||||||
|
|
||||||
|
create_epub(OUTPUT_DIR / 'test_jpeg_images.epub', 'JPEG Image Tests', jpeg_chapters)
|
||||||
|
|
||||||
|
print("Creating PNG test EPUB...")
|
||||||
|
png_chapters = [
|
||||||
|
("Introduction", make_chapter("PNG Image Tests", """
|
||||||
|
<p>This EPUB tests PNG image rendering.</p>
|
||||||
|
<p>Navigate through chapters to verify each test case.</p>
|
||||||
|
"""), []),
|
||||||
|
("1. PNG Format", make_chapter("PNG Format Test", """
|
||||||
|
<p>Basic PNG decoding test.</p>
|
||||||
|
<img src="images/png_format.png" alt="PNG format test"/>
|
||||||
|
<p>If the image above is visible and no crash occurred, PNG decoding works.</p>
|
||||||
|
"""), [('png_format.png', images['png_format.png'])]),
|
||||||
|
("2. Grayscale", make_chapter("Grayscale Test", """
|
||||||
|
<p>Verify 4 distinct gray levels are visible.</p>
|
||||||
|
<img src="images/grayscale_test.png" alt="Grayscale test"/>
|
||||||
|
"""), [('grayscale_test.png', images['grayscale_test.png'])]),
|
||||||
|
("3. Centering", make_chapter("Centering Test", """
|
||||||
|
<p>Verify image is centered horizontally.</p>
|
||||||
|
<img src="images/centering_test.png" alt="Centering test"/>
|
||||||
|
"""), [('centering_test.png', images['centering_test.png'])]),
|
||||||
|
("4. Scaling", make_chapter("Scaling Test", """
|
||||||
|
<p>This image is 1200x1500 pixels - larger than the screen.</p>
|
||||||
|
<p>It should be scaled down to fit.</p>
|
||||||
|
<img src="images/scaling_test.png" alt="Scaling test"/>
|
||||||
|
"""), [('scaling_test.png', images['scaling_test.png'])]),
|
||||||
|
("5. Cache Test A", make_chapter("Cache Test - Page A", """
|
||||||
|
<p>First cache test page. Note the load time.</p>
|
||||||
|
<img src="images/cache_test_1.png" alt="Cache test 1"/>
|
||||||
|
<p>Navigate to next page, then come back.</p>
|
||||||
|
"""), [('cache_test_1.png', images['cache_test_1.png'])]),
|
||||||
|
("6. Cache Test B", make_chapter("Cache Test - Page B", """
|
||||||
|
<p>Second cache test page.</p>
|
||||||
|
<img src="images/cache_test_2.png" alt="Cache test 2"/>
|
||||||
|
<p>Navigate back to Page A - it should load faster from cache.</p>
|
||||||
|
"""), [('cache_test_2.png', images['cache_test_2.png'])]),
|
||||||
|
]
|
||||||
|
|
||||||
|
create_epub(OUTPUT_DIR / 'test_png_images.epub', 'PNG Image Tests', png_chapters)
|
||||||
|
|
||||||
|
print("Creating mixed format test EPUB...")
|
||||||
|
mixed_chapters = [
|
||||||
|
("Introduction", make_chapter("Mixed Image Format Tests", """
|
||||||
|
<p>This EPUB contains both JPEG and PNG images.</p>
|
||||||
|
<p>Tests format detection and mixed rendering.</p>
|
||||||
|
"""), []),
|
||||||
|
("1. JPEG Image", make_chapter("JPEG in Mixed EPUB", """
|
||||||
|
<p>This is a JPEG image:</p>
|
||||||
|
<img src="images/jpeg_format.jpg" alt="JPEG"/>
|
||||||
|
"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]),
|
||||||
|
("2. PNG Image", make_chapter("PNG in Mixed EPUB", """
|
||||||
|
<p>This is a PNG image:</p>
|
||||||
|
<img src="images/png_format.png" alt="PNG"/>
|
||||||
|
"""), [('png_format.png', images['png_format.png'])]),
|
||||||
|
("3. Both Formats", make_chapter("Both Formats on One Page", """
|
||||||
|
<p>JPEG image:</p>
|
||||||
|
<img src="images/grayscale_test.jpg" alt="JPEG grayscale"/>
|
||||||
|
<p>PNG image:</p>
|
||||||
|
<img src="images/grayscale_test.png" alt="PNG grayscale"/>
|
||||||
|
<p>Both should render with proper grayscale.</p>
|
||||||
|
"""), [('grayscale_test.jpg', images['grayscale_test.jpg']),
|
||||||
|
('grayscale_test.png', images['grayscale_test.png'])]),
|
||||||
|
]
|
||||||
|
|
||||||
|
create_epub(OUTPUT_DIR / 'test_mixed_images.epub', 'Mixed Format Tests', mixed_chapters)
|
||||||
|
|
||||||
|
print(f"\nTest EPUBs created in: {OUTPUT_DIR}")
|
||||||
|
print("Files:")
|
||||||
|
for f in OUTPUT_DIR.glob('*.epub'):
|
||||||
|
print(f" - {f.name}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -22,6 +22,11 @@
|
|||||||
#include "util/BookmarkStore.h"
|
#include "util/BookmarkStore.h"
|
||||||
#include "util/Dictionary.h"
|
#include "util/Dictionary.h"
|
||||||
|
|
||||||
|
// Image refresh optimization strategy:
|
||||||
|
// 0 = Use double FAST_REFRESH technique (default, feels snappier)
|
||||||
|
// 1 = Use displayWindow() for partial refresh (experimental)
|
||||||
|
#define USE_IMAGE_DOUBLE_FAST_REFRESH 0
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||||
constexpr unsigned long skipChapterMs = 700;
|
constexpr unsigned long skipChapterMs = 700;
|
||||||
@@ -972,6 +977,14 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
|
|||||||
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
|
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
|
||||||
const int orientedMarginRight, const int orientedMarginBottom,
|
const int orientedMarginRight, const int orientedMarginBottom,
|
||||||
const int orientedMarginLeft) {
|
const int orientedMarginLeft) {
|
||||||
|
// Determine if this page needs special image handling
|
||||||
|
bool pageHasImages = page->hasImages();
|
||||||
|
bool useAntiAliasing = SETTINGS.textAntiAliasing;
|
||||||
|
|
||||||
|
// Force half refresh for pages with images when anti-aliasing is on,
|
||||||
|
// as grayscale tones require half refresh to display correctly
|
||||||
|
bool forceFullRefresh = pageHasImages && useAntiAliasing;
|
||||||
|
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||||
|
|
||||||
// Draw bookmark ribbon indicator in top-right corner if current page is bookmarked
|
// Draw bookmark ribbon indicator in top-right corner if current page is bookmarked
|
||||||
@@ -990,10 +1003,42 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
|
|
||||||
|
// Check if half-refresh is needed (either entering Reader or pages counter reached)
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
|
} else if (forceFullRefresh) {
|
||||||
|
// OPTIMIZATION: For image pages with anti-aliasing, use fast double-refresh technique
|
||||||
|
// to reduce perceived lag. Only when pagesUntilFullRefresh > 1 (screen already clean).
|
||||||
|
int imgX, imgY, imgW, imgH;
|
||||||
|
if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {
|
||||||
|
int screenX = imgX + orientedMarginLeft;
|
||||||
|
int screenY = imgY + orientedMarginTop;
|
||||||
|
LOG_DBG("ERS", "Image page: fast double-refresh (page bbox: %d,%d %dx%d, screen: %d,%d %dx%d)",
|
||||||
|
imgX, imgY, imgW, imgH, screenX, screenY, imgW, imgH);
|
||||||
|
|
||||||
|
#if USE_IMAGE_DOUBLE_FAST_REFRESH == 0
|
||||||
|
// Method A: Fill blank area + two FAST_REFRESH operations
|
||||||
|
renderer.fillRect(screenX, screenY, imgW, imgH, false);
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||||
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
#else
|
||||||
|
// Method B (experimental): Use displayWindow() for partial refresh
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||||
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
|
renderer.displayWindow(screenX, screenY, imgW, imgH, HalDisplay::FAST_REFRESH);
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
LOG_DBG("ERS", "Image page but no bbox, using standard half refresh");
|
||||||
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
|
}
|
||||||
|
pagesUntilFullRefresh--;
|
||||||
} else {
|
} else {
|
||||||
|
// Normal page without images, or images without anti-aliasing
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
pagesUntilFullRefresh--;
|
pagesUntilFullRefresh--;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user