diff --git a/README.md b/README.md index d59df835..4ce67a36 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec ## Features & Usage - [x] EPUB parsing and rendering (EPUB 2 and EPUB 3) -- [ ] Image support within EPUB +- [x] Image support within EPUB (JPEG and PNG) - [x] Saved reading position - [x] File explorer with file picker - [x] Basic EPUB picker from root directory diff --git a/lib/BmpWriter/BmpWriter.h b/lib/BmpWriter/BmpWriter.h new file mode 100644 index 00000000..6a0a2c3d --- /dev/null +++ b/lib/BmpWriter/BmpWriter.h @@ -0,0 +1,71 @@ +#pragma once + +#include + +#include + +// ============================================================================ +// BMP WRITING HELPERS +// ============================================================================ + +// Write 16-bit value in little-endian format +inline void write16(Print& out, const uint16_t value) { + out.write(value & 0xFF); + out.write((value >> 8) & 0xFF); +} + +// Write 32-bit unsigned value in little-endian format +inline void write32(Print& out, const uint32_t value) { + out.write(value & 0xFF); + out.write((value >> 8) & 0xFF); + out.write((value >> 16) & 0xFF); + out.write((value >> 24) & 0xFF); +} + +// Write 32-bit signed value in little-endian format +inline void write32Signed(Print& out, const int32_t value) { + out.write(value & 0xFF); + out.write((value >> 8) & 0xFF); + out.write((value >> 16) & 0xFF); + out.write((value >> 24) & 0xFF); +} + +// Write BMP header with 2-bit color depth (4-level grayscale) +static inline void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) { + // Calculate row padding (each row must be multiple of 4 bytes) + const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up + const int imageSize = bytesPerRow * height; + const uint32_t fileSize = 70 + imageSize; // 14 (file header) + 40 (DIB header) + 16 (palette) + image + + // BMP File Header (14 bytes) + bmpOut.write('B'); + bmpOut.write('M'); + write32(bmpOut, fileSize); // File size + write32(bmpOut, 0); // Reserved + write32(bmpOut, 70); // Offset to pixel data + + // DIB Header (BITMAPINFOHEADER - 40 bytes) + write32(bmpOut, 40); + write32Signed(bmpOut, width); + write32Signed(bmpOut, -height); // Negative height = top-down bitmap + write16(bmpOut, 1); // Color planes + write16(bmpOut, 2); // Bits per pixel (2 bits) + write32(bmpOut, 0); // BI_RGB (no compression) + write32(bmpOut, imageSize); + write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) + write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) + write32(bmpOut, 4); // colorsUsed + write32(bmpOut, 4); // colorsImportant + + // Color Palette (4 colors x 4 bytes = 16 bytes) + // Format: Blue, Green, Red, Reserved (BGRA) + uint8_t palette[16] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85) + 0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170) + 0xFF, 0xFF, 0xFF, 0x00 // Color 3: White + }; + for (const uint8_t i : palette) { + bmpOut.write(i); + } +} diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 1b337721..3b49c93f 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -324,6 +324,10 @@ void Epub::setupCacheDir() const { } SdMan.mkdir(cachePath.c_str()); + + // Create images subdirectory + const auto imagesDir = cachePath + "/images"; + SdMan.mkdir(imagesDir.c_str()); } const std::string& Epub::getCachePath() const { return cachePath; } @@ -353,6 +357,11 @@ std::string Epub::getCoverBmpPath(bool cropped) const { return cachePath + "/" + coverFileName + ".bmp"; } +std::string Epub::getImageCachePath(const int spineIndex, const int imageIndex) const { + const auto imagesDir = cachePath + "/images"; + return imagesDir + "/" + std::to_string(spineIndex) + "_" + std::to_string(imageIndex) + ".bmp"; +} + bool Epub::generateCoverBmp(bool cropped) const { // Already generated, return true if (SdMan.exists(getCoverBmpPath(cropped).c_str())) { diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 91062aa4..c9a371b4 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -45,6 +45,7 @@ class Epub { const std::string& getTitle() const; const std::string& getAuthor() const; std::string getCoverBmpPath(bool cropped = false) const; + std::string getImageCachePath(int spineIndex, int imageIndex) const; bool generateCoverBmp(bool cropped = false) const; std::string getThumbBmpPath() const; bool generateThumbBmp() const; diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 92839eb7..fe02d919 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -1,6 +1,9 @@ #include "Page.h" +#include +#include #include +#include #include void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { @@ -25,6 +28,53 @@ std::unique_ptr PageLine::deserialize(FsFile& file) { return std::unique_ptr(new PageLine(std::move(tb), xPos, yPos)); } +void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { + FsFile bmpFile; + if (!SdMan.openFileForRead("PGI", cachedBmpPath, bmpFile)) { + Serial.printf("[%lu] [PGI] Failed to open cached BMP: %s\n", millis(), cachedBmpPath.c_str()); + return; + } + + Bitmap bitmap(bmpFile); + if (bitmap.parseHeaders() != BmpReaderError::Ok) { + Serial.printf("[%lu] [PGI] Failed to parse BMP headers\n", millis()); + bmpFile.close(); + return; + } + + // Calculate viewport dimensions (480x800 portrait) + const int viewportWidth = 480; + const int viewportHeight = 800; + + // Render centered on screen, ignoring text margins + // Images should fill the screen, not respect text padding + renderer.drawBitmap(bitmap, 0, 0, viewportWidth, viewportHeight); + bmpFile.close(); +} + +bool PageImage::serialize(FsFile& file) { + serialization::writePod(file, xPos); + serialization::writePod(file, yPos); + serialization::writeString(file, cachedBmpPath); + serialization::writePod(file, imageWidth); + serialization::writePod(file, imageHeight); + return true; +} + +std::unique_ptr PageImage::deserialize(FsFile& file) { + int16_t xPos, yPos; + uint16_t imageWidth, imageHeight; + std::string cachedBmpPath; + + serialization::readPod(file, xPos); + serialization::readPod(file, yPos); + serialization::readString(file, cachedBmpPath); + serialization::readPod(file, imageWidth); + serialization::readPod(file, imageHeight); + + return std::unique_ptr(new PageImage(std::move(cachedBmpPath), imageWidth, imageHeight, xPos, yPos)); +} + void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const { for (auto& element : elements) { element->render(renderer, fontId, xOffset, yOffset); @@ -36,8 +86,9 @@ bool Page::serialize(FsFile& file) const { serialization::writePod(file, count); for (const auto& el : elements) { - // Only PageLine exists currently - serialization::writePod(file, static_cast(TAG_PageLine)); + // Get element type tag via virtual function + const PageElementTag tag = el->getTag(); + serialization::writePod(file, static_cast(tag)); if (!el->serialize(file)) { return false; } @@ -59,6 +110,9 @@ std::unique_ptr Page::deserialize(FsFile& file) { if (tag == TAG_PageLine) { auto pl = PageLine::deserialize(file); page->elements.push_back(std::move(pl)); + } else if (tag == TAG_PageImage) { + auto pi = PageImage::deserialize(file); + page->elements.push_back(std::move(pi)); } else { Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag); return nullptr; diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 20061941..6f717105 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -8,6 +9,7 @@ enum PageElementTag : uint8_t { TAG_PageLine = 1, + TAG_PageImage = 2, }; // represents something that has been added to a page @@ -19,6 +21,7 @@ class PageElement { virtual ~PageElement() = default; virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0; virtual bool serialize(FsFile& file) = 0; + virtual PageElementTag getTag() const = 0; }; // a line from a block element @@ -30,9 +33,29 @@ class PageLine final : public PageElement { : PageElement(xPos, yPos), block(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_PageLine; } static std::unique_ptr deserialize(FsFile& file); }; +// an image element on a page +class PageImage final : public PageElement { + std::string cachedBmpPath; + uint16_t imageWidth; + uint16_t imageHeight; + + public: + PageImage(std::string cachedBmpPath, const uint16_t imageWidth, const uint16_t imageHeight, const int16_t xPos, + const int16_t yPos) + : PageElement(xPos, yPos), + cachedBmpPath(std::move(cachedBmpPath)), + imageWidth(imageWidth), + imageHeight(imageHeight) {} + 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 deserialize(FsFile& file); +}; + class Page { public: // the list of block index and line numbers on this page @@ -40,4 +63,10 @@ class Page { void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; bool serialize(FsFile& file) const; static std::unique_ptr deserialize(FsFile& file); + + // Check if page contains any images + bool hasImages() const { + return std::any_of(elements.begin(), elements.end(), + [](const std::shared_ptr& element) { return element->getTag() == TAG_PageImage; }); + } }; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 18b81aae..40493ffc 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -175,11 +175,21 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c viewportHeight); std::vector lut = {}; + // Get spine item directory for resolving relative image paths + std::string spineItemDir; + if (epub) { + const auto spineEntry = epub->getSpineItem(spineIndex); + const auto lastSlash = spineEntry.href.find_last_of('/'); + if (lastSlash != std::string::npos) { + spineItemDir = spineEntry.href.substr(0, lastSlash + 1); + } + } + ChapterHtmlSlimParser visitor( tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight, - [this, &lut](std::unique_ptr page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, - progressFn); + [this, &lut](std::unique_ptr page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, progressFn, + epub, spineIndex, spineItemDir); success = visitor.parseAndBuildPages(); SdMan.remove(tmpHtmlPath.c_str()); diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index acddd81d..239212d0 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1,10 +1,15 @@ #include "ChapterHtmlSlimParser.h" +#include +#include #include #include +#include +#include #include #include +#include "../../Epub.h" #include "../Page.h" const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; @@ -54,6 +59,145 @@ void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) { currentTextBlock.reset(new ParsedText(style, extraParagraphSpacing)); } +void ChapterHtmlSlimParser::processImageTag(const XML_Char** atts) { + // Images only supported if epub context provided + if (!epub) { + return; + } + + // Extract src attribute + std::string src; + for (int i = 0; atts[i]; i += 2) { + if (strcmp(atts[i], "src") == 0) { + src = atts[i + 1]; + break; + } + } + + if (src.empty()) { + Serial.printf("[%lu] [EHP] Image tag without src attribute\n", millis()); + return; + } + + // Detect image type + const bool isJpeg = (src.length() >= 4 && src.substr(src.length() - 4) == ".jpg") || + (src.length() >= 5 && src.substr(src.length() - 5) == ".jpeg"); + const bool isPng = (src.length() >= 4 && src.substr(src.length() - 4) == ".png"); + + if (!isJpeg && !isPng) { + Serial.printf("[%lu] [EHP] Skipping unsupported image format: %s\n", millis(), src.c_str()); + return; + } + + Serial.printf("[%lu] [EHP] Processing inline %s image: %s\n", millis(), isJpeg ? "JPEG" : "PNG", src.c_str()); + + // Resolve relative path (src is relative to current HTML file's directory) + const std::string imagePath = FsHelpers::normalisePath(spineItemDir + src); + Serial.printf("[%lu] [EHP] Resolved image path: %s\n", millis(), imagePath.c_str()); + const std::string cachedBmpPath = epub->getImageCachePath(spineIndex, imageCounter); + imageCounter++; + + // Check if BMP already cached + if (!SdMan.exists(cachedBmpPath.c_str())) { + // Extract image from EPUB to temp file + const std::string tempExt = isJpeg ? ".jpg" : ".png"; + const std::string tempImagePath = epub->getCachePath() + "/.tmp_img" + tempExt; + + FsFile tempImage; + if (!SdMan.openFileForWrite("EHP", tempImagePath, tempImage)) { + Serial.printf("[%lu] [EHP] Failed to create temp image file\n", millis()); + return; + } + + if (!epub->readItemContentsToStream(imagePath, tempImage, 1024)) { + Serial.printf("[%lu] [EHP] Failed to extract image from EPUB: %s\n", millis(), imagePath.c_str()); + tempImage.close(); + SdMan.remove(tempImagePath.c_str()); + return; + } + tempImage.close(); + + // Convert to BMP + if (!SdMan.openFileForRead("EHP", tempImagePath, tempImage)) { + Serial.printf("[%lu] [EHP] Failed to reopen temp image\n", millis()); + SdMan.remove(tempImagePath.c_str()); + return; + } + + FsFile bmpFile; + if (!SdMan.openFileForWrite("EHP", cachedBmpPath, bmpFile)) { + Serial.printf("[%lu] [EHP] Failed to create BMP cache file\n", millis()); + tempImage.close(); + SdMan.remove(tempImagePath.c_str()); + return; + } + + // Route to appropriate converter + bool success; + if (isJpeg) { + success = JpegToBmpConverter::jpegFileToBmpStream(tempImage, bmpFile); + } else { + success = PngToBmpConverter::pngFileToBmpStream(tempImage, bmpFile); + } + + tempImage.close(); + bmpFile.close(); + SdMan.remove(tempImagePath.c_str()); + + if (!success) { + Serial.printf("[%lu] [EHP] %s to BMP conversion failed\n", millis(), isJpeg ? "JPEG" : "PNG"); + SdMan.remove(cachedBmpPath.c_str()); + return; + } + + Serial.printf("[%lu] [EHP] Cached image to: %s\n", millis(), cachedBmpPath.c_str()); + } + + // Read BMP dimensions to calculate page placement + FsFile bmpFile; + if (!SdMan.openFileForRead("EHP", cachedBmpPath, bmpFile)) { + Serial.printf("[%lu] [EHP] Failed to read cached BMP\n", millis()); + return; + } + + Bitmap bitmap(bmpFile); + if (bitmap.parseHeaders() != BmpReaderError::Ok) { + Serial.printf("[%lu] [EHP] Failed to parse BMP headers\n", millis()); + bmpFile.close(); + return; + } + + const int imageWidth = bitmap.getWidth(); + const int imageHeight = bitmap.getHeight(); + bmpFile.close(); + + // Flush current text block to complete current page + if (currentTextBlock && !currentTextBlock->isEmpty()) { + makePages(); + } + + // Start new page for image + if (currentPage) { + completePageFn(std::move(currentPage)); + } + currentPage.reset(new Page()); + currentPageNextY = 0; + + // Add image to page (centered horizontally, top of page) + const int xPos = 0; // GfxRenderer::drawBitmap centers automatically + const int yPos = 0; + + currentPage->elements.push_back(std::make_shared(cachedBmpPath, imageWidth, imageHeight, xPos, yPos)); + + // Complete the image page + completePageFn(std::move(currentPage)); + currentPage = nullptr; + currentPageNextY = 0; + + // Start fresh text block for content after image + currentTextBlock.reset(new ParsedText((TextBlock::Style)paragraphAlignment, extraParagraphSpacing)); +} + void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { auto* self = static_cast(userData); @@ -78,27 +222,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { - // TODO: Start processing image tags - std::string alt; - if (atts != nullptr) { - for (int i = 0; atts[i]; i += 2) { - if (strcmp(atts[i], "alt") == 0) { - alt = "[Image: " + std::string(atts[i + 1]) + "]"; - } - } - Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str()); - - self->startNewTextBlock(TextBlock::CENTER_ALIGN); - self->italicUntilDepth = min(self->italicUntilDepth, self->depth); - self->depth += 1; - self->characterData(userData, alt.c_str(), alt.length()); - - } else { - // Skip for now - self->skipUntilDepth = self->depth; - self->depth += 1; - return; - } + // Process image tag + self->processImageTag(atts); + self->skipUntilDepth = self->depth; + self->depth += 1; + return; } if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) { @@ -349,7 +477,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { file.close(); // Process last page if there is still text - if (currentTextBlock) { + if (currentTextBlock && !currentTextBlock->isEmpty()) { makePages(); completePageFn(std::move(currentPage)); currentPage.reset(); diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index c559e157..58a8231f 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -11,6 +11,7 @@ class Page; class GfxRenderer; +class Epub; #define MAX_WORD_SIZE 200 @@ -36,8 +37,13 @@ class ChapterHtmlSlimParser { uint8_t paragraphAlignment; uint16_t viewportWidth; uint16_t viewportHeight; + std::shared_ptr epub; + int spineIndex; + std::string spineItemDir; + int imageCounter; void startNewTextBlock(TextBlock::Style style); + void processImageTag(const XML_Char** atts); void makePages(); // XML callbacks static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); @@ -50,7 +56,9 @@ class ChapterHtmlSlimParser { const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const std::function)>& completePageFn, - const std::function& progressFn = nullptr) + const std::function& progressFn = nullptr, + std::shared_ptr epub = nullptr, int spineIndex = 0, + const std::string& spineItemDir = "") : filepath(filepath), renderer(renderer), fontId(fontId), @@ -60,7 +68,11 @@ class ChapterHtmlSlimParser { viewportWidth(viewportWidth), viewportHeight(viewportHeight), completePageFn(completePageFn), - progressFn(progressFn) {} + progressFn(progressFn), + epub(std::move(epub)), + spineIndex(spineIndex), + spineItemDir(spineItemDir), + imageCounter(0) {} ~ChapterHtmlSlimParser() = default; bool parseAndBuildPages(); void addLineToPage(std::shared_ptr line); diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index 465593e8..6a1e489c 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -3,9 +3,9 @@ #include // Brightness/Contrast adjustments: -constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments +constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) -constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) +constexpr bool GAMMA_CORRECTION = true; // Gamma curve (brightens midtones) constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 01451a05..1cea1bfa 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -8,6 +8,7 @@ #include #include "BitmapHelpers.h" +#include "BmpWriter.h" // Context structure for picojpeg callback struct JpegReadContext { @@ -31,25 +32,6 @@ constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height) // ============================================================================ -inline void write16(Print& out, const uint16_t value) { - out.write(value & 0xFF); - out.write((value >> 8) & 0xFF); -} - -inline void write32(Print& out, const uint32_t value) { - out.write(value & 0xFF); - out.write((value >> 8) & 0xFF); - out.write((value >> 16) & 0xFF); - out.write((value >> 24) & 0xFF); -} - -inline void write32Signed(Print& out, const int32_t value) { - out.write(value & 0xFF); - out.write((value >> 8) & 0xFF); - out.write((value >> 16) & 0xFF); - out.write((value >> 24) & 0xFF); -} - // Helper function: Write BMP header with 8-bit grayscale (256 levels) void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { // Calculate row padding (each row must be multiple of 4 bytes) @@ -126,46 +108,6 @@ static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) } } -// Helper function: Write BMP header with 2-bit color depth -static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) { - // Calculate row padding (each row must be multiple of 4 bytes) - const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up - const int imageSize = bytesPerRow * height; - const uint32_t fileSize = 70 + imageSize; // 14 (file header) + 40 (DIB header) + 16 (palette) + image - - // BMP File Header (14 bytes) - bmpOut.write('B'); - bmpOut.write('M'); - write32(bmpOut, fileSize); // File size - write32(bmpOut, 0); // Reserved - write32(bmpOut, 70); // Offset to pixel data - - // DIB Header (BITMAPINFOHEADER - 40 bytes) - write32(bmpOut, 40); - write32Signed(bmpOut, width); - write32Signed(bmpOut, -height); // Negative height = top-down bitmap - write16(bmpOut, 1); // Color planes - write16(bmpOut, 2); // Bits per pixel (2 bits) - write32(bmpOut, 0); // BI_RGB (no compression) - write32(bmpOut, imageSize); - write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) - write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) - write32(bmpOut, 4); // colorsUsed - write32(bmpOut, 4); // colorsImportant - - // Color Palette (4 colors x 4 bytes = 16 bytes) - // Format: Blue, Green, Red, Reserved (BGRA) - uint8_t palette[16] = { - 0x00, 0x00, 0x00, 0x00, // Color 0: Black - 0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85) - 0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170) - 0xFF, 0xFF, 0xFF, 0x00 // Color 3: White - }; - for (const uint8_t i : palette) { - bmpOut.write(i); - } -} - // Callback function for picojpeg to read JPEG data unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const unsigned char buf_size, unsigned char* pBytes_actually_read, void* pCallback_data) { diff --git a/lib/PngToBmpConverter/PngToBmpConverter.cpp b/lib/PngToBmpConverter/PngToBmpConverter.cpp new file mode 100644 index 00000000..9cc799ef --- /dev/null +++ b/lib/PngToBmpConverter/PngToBmpConverter.cpp @@ -0,0 +1,273 @@ +#include "PngToBmpConverter.h" + +#include +#include +#include + +#include +#include + +#include "BitmapHelpers.h" +#include "BmpWriter.h" + +static void write2BitRow(Print& out, const uint8_t* pixels, int width) { + // Pack 4 pixels per byte (2 bits each) + int x = 0; + while (x < width) { + uint8_t packed = 0; + // Pack up to 4 pixels into one byte (MSB first) + for (int shift = 6; shift >= 0 && x < width; shift -= 2, x++) { + packed |= (pixels[x] & 0x03) << shift; + } + out.write(packed); + } + + // Add row padding (BMP rows must be aligned to 4 bytes) + const int bytesPerRow = (width * 2 + 7) / 8; // Round up to nearest byte + const int paddedRow = (bytesPerRow + 3) / 4 * 4; // Round up to multiple of 4 + const int padding = paddedRow - bytesPerRow; + for (int i = 0; i < padding; i++) { + out.write(static_cast(0)); + } +} + +// ============================================================================ +// COLOR CONVERSION HELPERS +// ============================================================================ + +// Convert RGB to grayscale using weighted formula +static inline uint8_t rgbToGray(uint8_t r, uint8_t g, uint8_t b) { + // Weighted average: R*0.25 + G*0.50 + B*0.25 + // Using integer math: (R*25 + G*50 + B*25) / 100 + return (r * 25 + g * 50 + b * 25) / 100; +} + +// Blend foreground color with white background using alpha +static inline uint8_t blendAlpha(uint8_t fg, uint8_t alpha) { + // result = (fg * alpha + 255 * (255 - alpha)) / 255 + // Simplifies to: (fg * alpha) / 255 + (255 - alpha) + return ((fg * alpha) >> 8) + (255 - alpha); +} + +// Context for draw callback +struct PngDrawContext { + Print* bmpOut; // BMP output stream + AtkinsonDitherer* ditherer; // Dithering engine + uint8_t* rowBuffer; // 2-bit output buffer for one row + int width; // Output width + int height; // Output height +}; + +// Note: We use openRAM() instead of custom file callbacks for simplicity +// PNG files in EPUBs are typically small (< 100KB), so loading into RAM is acceptable + +// ============================================================================ +// IMAGE PROCESSING OPTIONS +// ============================================================================ +constexpr bool USE_ATKINSON = true; // Use Atkinson dithering +constexpr bool USE_PRESCALE = false; // TEMPORARILY DISABLED - Pre-scale to fit display +constexpr int TARGET_MAX_WIDTH = 480; // Max width for display +constexpr int TARGET_MAX_HEIGHT = 800; // Max height for display +constexpr int MAX_IMAGE_WIDTH = 2048; // Safety limit +constexpr int MAX_IMAGE_HEIGHT = 3072; // Safety limit +// ============================================================================ + +// PNG draw callback - process each scanline +int pngDraw(PNGDRAW* pDraw) { + if (!pDraw || !pDraw->pUser) { + return 0; + } + + auto* ctx = static_cast(pDraw->pUser); + const int y = pDraw->y; + const int width = pDraw->iWidth; + const uint8_t* pixels = pDraw->pPixels; + + // Convert pixels to grayscale and apply dithering + for (int x = 0; x < width; x++) { + uint8_t gray; + + // Convert pixel to grayscale based on format + if (pDraw->iPixelType == PNG_PIXEL_TRUECOLOR) { + // RGB format (3 bytes per pixel) + const uint8_t r = pixels[x * 3]; + const uint8_t g = pixels[x * 3 + 1]; + const uint8_t b = pixels[x * 3 + 2]; + gray = rgbToGray(r, g, b); + } else if (pDraw->iPixelType == PNG_PIXEL_TRUECOLOR_ALPHA) { + // RGBA format (4 bytes per pixel) + const uint8_t r = pixels[x * 4]; + const uint8_t g = pixels[x * 4 + 1]; + const uint8_t b = pixels[x * 4 + 2]; + const uint8_t a = pixels[x * 4 + 3]; + // Blend with white background + const uint8_t r_blend = blendAlpha(r, a); + const uint8_t g_blend = blendAlpha(g, a); + const uint8_t b_blend = blendAlpha(b, a); + gray = rgbToGray(r_blend, g_blend, b_blend); + } else if (pDraw->iPixelType == PNG_PIXEL_GRAYSCALE) { + // Already grayscale + gray = pixels[x]; + } else { + // Unsupported format, use white + gray = 255; + } + + // Apply brightness/contrast/gamma adjustments + gray = adjustPixel(gray); + + // Apply dithering and quantize to 2-bit + ctx->rowBuffer[x] = ctx->ditherer->processPixel(gray, x); + } + + // Write row to BMP output + write2BitRow(*ctx->bmpOut, ctx->rowBuffer, width); + + // Advance ditherer to next row + ctx->ditherer->nextRow(); + + return 1; // Continue decoding +} + +// ============================================================================ +// IMAGE CONVERSION +// ============================================================================ + +// Core function: Convert PNG file to 2-bit BMP +// NOTE: This function expects pngFile to be a temp file on the SD card +// The caller should extract the PNG from EPUB to a temp file first +bool PngToBmpConverter::pngFileToBmpStream(FsFile& pngFile, Print& bmpOut) { + Serial.printf("[%lu] [PNG] Converting PNG to BMP\n", millis()); + + // Color processing settings are configured in BitmapHelpers.cpp: + // USE_BRIGHTNESS=true, BRIGHTNESS_BOOST=10, GAMMA_CORRECTION=true, CONTRAST_FACTOR=1.15f + + // Check file is valid and get size + if (!pngFile.isOpen()) { + Serial.printf("[%lu] [PNG] PNG file is not open\n", millis()); + return false; + } + + const int32_t fileSize = pngFile.size(); + if (fileSize <= 0) { + Serial.printf("[%lu] [PNG] Invalid PNG file size: %d\n", millis(), fileSize); + return false; + } + + Serial.printf("[%lu] [PNG] Loading PNG file into RAM (%d bytes)\n", millis(), fileSize); + + // Read entire PNG file into memory + // Most PNG images in EPUBs are < 100KB, which is acceptable for our RAM budget + auto* pngData = static_cast(malloc(fileSize)); + if (!pngData) { + Serial.printf("[%lu] [PNG] Failed to allocate %d bytes for PNG data\n", millis(), fileSize); + return false; + } + + pngFile.rewind(); + const int32_t bytesRead = pngFile.read(pngData, fileSize); + pngFile.close(); // Close file early to free up resources + + if (bytesRead != fileSize) { + Serial.printf("[%lu] [PNG] Failed to read PNG file: read %d bytes, expected %d\n", millis(), bytesRead, fileSize); + free(pngData); + return false; + } + + // Open PNG from RAM + // Allocate PNG decoder on heap - it's ~36KB and would overflow the stack + PNG* png = new PNG(); + if (!png) { + Serial.printf("[%lu] [PNG] Failed to allocate PNG decoder\n", millis()); + free(pngData); + return false; + } + + // Open with callback for line-by-line processing + const int rc = png->openRAM(pngData, fileSize, pngDraw); + if (rc != PNG_SUCCESS) { + Serial.printf("[%lu] [PNG] Failed to open PNG from RAM: error code %d\n", millis(), png->getLastError()); + delete png; + free(pngData); + return false; + } + + const int srcWidth = png->getWidth(); + const int srcHeight = png->getHeight(); + const int bpp = png->getBpp(); + const bool hasAlpha = png->hasAlpha(); + + Serial.printf("[%lu] [PNG] PNG dimensions: %dx%d, bpp: %d, alpha: %d\n", millis(), srcWidth, srcHeight, bpp, + hasAlpha); + + // Safety limits + if (srcWidth > MAX_IMAGE_WIDTH || srcHeight > MAX_IMAGE_HEIGHT) { + Serial.printf("[%lu] [PNG] Image too large (%dx%d), max supported: %dx%d\n", millis(), srcWidth, srcHeight, + MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT); + png->close(); + delete png; + free(pngData); + return false; + } + + // Note: For now, we don't pre-scale PNGs - just output at native resolution + // The display system will handle scaling/centering + const int outWidth = srcWidth; + const int outHeight = srcHeight; + + Serial.printf("[%lu] [PNG] Output dimensions: %dx%d\n", millis(), outWidth, outHeight); + + // Write BMP header + writeBmpHeader2bit(bmpOut, outWidth, outHeight); + + // Allocate ditherer + AtkinsonDitherer* ditherer = new AtkinsonDitherer(outWidth); + if (!ditherer) { + Serial.printf("[%lu] [PNG] Failed to allocate ditherer\n", millis()); + png->close(); + delete png; + free(pngData); + return false; + } + + // Allocate row buffer for 2-bit output + auto* rowBuffer = static_cast(malloc(outWidth)); + if (!rowBuffer) { + Serial.printf("[%lu] [PNG] Failed to allocate row buffer (%d bytes)\n", millis(), outWidth); + delete ditherer; + png->close(); + delete png; + free(pngData); + return false; + } + + // Setup context for callback + PngDrawContext ctx; + ctx.bmpOut = &bmpOut; + ctx.ditherer = ditherer; + ctx.rowBuffer = rowBuffer; + ctx.width = outWidth; + ctx.height = outHeight; + + // Decode with callback - this will call pngDraw for each scanline + Serial.printf("[%lu] [PNG] Starting line-by-line decode and conversion\n", millis()); + const int decodeRc = png->decode(&ctx, 0); + const bool success = (decodeRc == PNG_SUCCESS); + + // Cleanup buffers + free(rowBuffer); + delete ditherer; + + if (!success) { + Serial.printf("[%lu] [PNG] PNG decode failed: error code %d\n", millis(), png->getLastError()); + } else { + Serial.printf("[%lu] [PNG] Decode succeeded!\n", millis()); + } + + // Cleanup + png->close(); + delete png; + free(pngData); + + return success; +} diff --git a/lib/PngToBmpConverter/PngToBmpConverter.h b/lib/PngToBmpConverter/PngToBmpConverter.h new file mode 100644 index 00000000..000bc683 --- /dev/null +++ b/lib/PngToBmpConverter/PngToBmpConverter.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +class PngToBmpConverter { + public: + // Convert PNG file to 2-bit BMP stream + // Similar API to JpegToBmpConverter for consistency + static bool pngFileToBmpStream(FsFile& pngFile, Print& bmpOut); +}; diff --git a/platformio.ini b/platformio.ini index ef27ffd5..a9f83983 100644 --- a/platformio.ini +++ b/platformio.ini @@ -30,6 +30,9 @@ build_flags = -std=c++2a # Enable UTF-8 long file names in SdFat -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_build.flash_mode = dio @@ -47,6 +50,7 @@ lib_deps = SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager bblanchon/ArduinoJson @ 7.4.2 ricmoo/QRCode @ 0.0.1 + bitbank2/PNGdec @ ^1.0.0 links2004/WebSockets @ 2.7.3 [env:default] diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 2eeba80f..300a4d3b 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -385,30 +385,34 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or pagesUntilFullRefresh--; } - // Save bw buffer to reset buffer state after grayscale data sync - renderer.storeBwBuffer(); + // Skip grayscale font rendering for image pages + // Images already have proper dithering from JPEG-to-BMP conversion + if (!page->hasImages()) { + // Save bw buffer to reset buffer state after grayscale data sync + renderer.storeBwBuffer(); - // grayscale rendering - // TODO: Only do this if font supports it - if (SETTINGS.textAntiAliasing) { - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderer.copyGrayscaleLsbBuffers(); + // grayscale rendering + // TODO: Only do this if font supports it + if (SETTINGS.textAntiAliasing) { + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + renderer.copyGrayscaleLsbBuffers(); - // Render and copy to MSB buffer - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderer.copyGrayscaleMsbBuffers(); + // Render and copy to MSB buffer + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + renderer.copyGrayscaleMsbBuffers(); - // display grayscale part - renderer.displayGrayBuffer(); - renderer.setRenderMode(GfxRenderer::BW); + // display grayscale part + renderer.displayGrayBuffer(); + renderer.setRenderMode(GfxRenderer::BW); + } + + // restore the bw data + renderer.restoreBwBuffer(); } - - // restore the bw data - renderer.restoreBwBuffer(); } void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,