feat: add inline jpg and png images to epub

This commit is contained in:
Martin Brook
2026-01-04 20:51:56 +00:00
parent 21277e03eb
commit 464f7a8189
15 changed files with 658 additions and 110 deletions

View File

@@ -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())) {

View File

@@ -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;

View File

@@ -1,6 +1,9 @@
#include "Page.h"
#include <Bitmap.h>
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
@@ -25,6 +28,53 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
}
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
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> 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<PageImage>(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<uint8_t>(TAG_PageLine));
// Get element type tag via virtual function
const PageElementTag tag = el->getTag();
serialization::writePod(file, static_cast<uint8_t>(tag));
if (!el->serialize(file)) {
return false;
}
@@ -59,6 +110,9 @@ std::unique_ptr<Page> 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;

View File

@@ -1,6 +1,7 @@
#pragma once
#include <SdFat.h>
#include <algorithm>
#include <utility>
#include <vector>
@@ -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<PageLine> 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<PageImage> 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<Page> deserialize(FsFile& file);
// Check if page contains any images
bool hasImages() const {
return std::any_of(elements.begin(), elements.end(),
[](const std::shared_ptr<PageElement>& element) { return element->getTag() == TAG_PageImage; });
}
};

View File

@@ -175,11 +175,21 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
viewportHeight);
std::vector<uint32_t> 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> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
progressFn);
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, progressFn,
epub, spineIndex, spineItemDir);
success = visitor.parseAndBuildPages();
SdMan.remove(tmpHtmlPath.c_str());

View File

@@ -1,10 +1,15 @@
#include "ChapterHtmlSlimParser.h"
#include <Bitmap.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <JpegToBmpConverter.h>
#include <PngToBmpConverter.h>
#include <SDCardManager.h>
#include <expat.h>
#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<PageImage>(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<ChapterHtmlSlimParser*>(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();

View File

@@ -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> 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<void(std::unique_ptr<Page>)>& completePageFn,
const std::function<void(int)>& progressFn = nullptr)
const std::function<void(int)>& progressFn = nullptr,
std::shared_ptr<Epub> 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<TextBlock> line);