add support for images

This commit is contained in:
altsysrq 2025-12-30 18:41:10 -06:00
parent 40f9ed485c
commit 9db4ef6f4b
10 changed files with 396 additions and 9 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use nix

32
flake.nix Normal file
View File

@ -0,0 +1,32 @@
{
description = "CrossPoint Reader - ESP32 E-Paper Firmware";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
platformio
python3
git
];
shellHook = ''
echo "CrossPoint Reader development environment"
echo "Commands:"
echo " pio run - Build firmware"
echo " pio run -t upload - Build and flash to device"
echo " pio run -t clean - Clean build artifacts"
'';
};
}
);
}

View File

@ -1,6 +1,9 @@
#include "Page.h" #include "Page.h"
#include <Bitmap.h>
#include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h> #include <Serialization.h>
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
@ -25,6 +28,50 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos)); return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
} }
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
FsFile imageFile;
if (!SdMan.openFileForRead("PGI", cachePath, imageFile)) {
Serial.printf("[%lu] [PGI] Failed to open image: %s\n", millis(), cachePath.c_str());
return;
}
Bitmap bitmap(imageFile);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
Serial.printf("[%lu] [PGI] Failed to parse image headers: %s\n", millis(), cachePath.c_str());
imageFile.close();
return;
}
// Draw the bitmap at the specified position
renderer.drawBitmap(bitmap, xPos + xOffset, yPos + yOffset, width, height);
imageFile.close();
}
bool PageImage::serialize(FsFile& file) {
serialization::writePod(file, xPos);
serialization::writePod(file, yPos);
serialization::writePod(file, width);
serialization::writePod(file, height);
serialization::writeString(file, cachePath);
return true;
}
std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
int16_t xPos;
int16_t yPos;
int width;
int height;
std::string cachePath;
serialization::readPod(file, xPos);
serialization::readPod(file, yPos);
serialization::readPod(file, width);
serialization::readPod(file, height);
serialization::readString(file, cachePath);
return std::unique_ptr<PageImage>(new PageImage(std::move(cachePath), width, height, xPos, yPos));
}
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const { void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
for (auto& element : elements) { for (auto& element : elements) {
element->render(renderer, fontId, xOffset, yOffset); element->render(renderer, fontId, xOffset, yOffset);
@ -36,8 +83,9 @@ bool Page::serialize(FsFile& file) const {
serialization::writePod(file, count); serialization::writePod(file, count);
for (const auto& el : elements) { for (const auto& el : elements) {
// Only PageLine exists currently // Write element tag
serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine)); serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
if (!el->serialize(file)) { if (!el->serialize(file)) {
return false; return false;
} }
@ -59,6 +107,9 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
if (tag == TAG_PageLine) { if (tag == TAG_PageLine) {
auto pl = PageLine::deserialize(file); auto pl = PageLine::deserialize(file);
page->elements.push_back(std::move(pl)); page->elements.push_back(std::move(pl));
} else if (tag == TAG_PageImage) {
auto pi = PageImage::deserialize(file);
page->elements.push_back(std::move(pi));
} else { } else {
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag); Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag);
return nullptr; return nullptr;

View File

@ -8,6 +8,7 @@
enum PageElementTag : uint8_t { enum PageElementTag : uint8_t {
TAG_PageLine = 1, TAG_PageLine = 1,
TAG_PageImage = 2,
}; };
// represents something that has been added to a page // represents something that has been added to a page
@ -19,6 +20,7 @@ class PageElement {
virtual ~PageElement() = default; virtual ~PageElement() = default;
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0; virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
virtual bool serialize(FsFile& file) = 0; virtual bool serialize(FsFile& file) = 0;
virtual PageElementTag getTag() const = 0;
}; };
// a line from a block element // a line from a block element
@ -30,9 +32,27 @@ class PageLine final : public PageElement {
: PageElement(xPos, yPos), block(std::move(block)) {} : PageElement(xPos, yPos), block(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override; bool serialize(FsFile& file) override;
PageElementTag getTag() const override { return TAG_PageLine; }
static std::unique_ptr<PageLine> deserialize(FsFile& file); static std::unique_ptr<PageLine> deserialize(FsFile& file);
}; };
// an image element
class PageImage final : public PageElement {
std::string cachePath;
int width;
int height;
public:
PageImage(std::string path, int w, int h, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), cachePath(std::move(path)), width(w), height(h) {}
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);
int getWidth() const { return width; }
int getHeight() const { return height; }
};
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

View File

@ -7,7 +7,7 @@
#include "parsers/ChapterHtmlSlimParser.h" #include "parsers/ChapterHtmlSlimParser.h"
namespace { namespace {
constexpr uint8_t SECTION_FILE_VERSION = 7; constexpr uint8_t SECTION_FILE_VERSION = 8; // Incremented for image support
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(int) + constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(int) +
sizeof(int) + sizeof(int) + sizeof(uint32_t); sizeof(int) + sizeof(int) + sizeof(uint32_t);
} // namespace } // namespace
@ -169,8 +169,8 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
std::vector<uint32_t> lut = {}; std::vector<uint32_t> lut = {};
ChapterHtmlSlimParser visitor( ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight, tmpHtmlPath, renderer, epub.get(), spineIndex, fontId, lineCompression, extraParagraphSpacing, viewportWidth,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, viewportHeight, [this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
progressFn); progressFn);
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();

View File

@ -0,0 +1,25 @@
#include "ImageBlock.h"
#include <GfxRenderer.h>
void ImageBlock::layout(GfxRenderer& renderer) {
// ImageBlock doesn't need layout - dimensions are already known
}
void ImageBlock::getScaledDimensions(const int viewportWidth, const int viewportHeight, int* outWidth,
int* outHeight) const {
if (width <= viewportWidth && height <= viewportHeight) {
// Image fits, no scaling needed
*outWidth = width;
*outHeight = height;
return;
}
// Calculate scale factor to fit within viewport
float scaleX = static_cast<float>(viewportWidth) / static_cast<float>(width);
float scaleY = static_cast<float>(viewportHeight) / static_cast<float>(height);
float scale = (scaleX < scaleY) ? scaleX : scaleY;
*outWidth = static_cast<int>(static_cast<float>(width) * scale);
*outHeight = static_cast<int>(static_cast<float>(height) * scale);
}

View File

@ -0,0 +1,28 @@
#pragma once
#include <string>
#include "Block.h"
class GfxRenderer;
// Represents an image block in the HTML document
class ImageBlock final : public Block {
public:
std::string cachePath; // Path to cached BMP file
int width;
int height;
bool isCached;
ImageBlock() : width(0), height(0), isCached(false) {}
ImageBlock(std::string path, int w, int h)
: cachePath(std::move(path)), width(w), height(h), isCached(true) {}
void layout(GfxRenderer& renderer) override;
BlockType getType() override { return IMAGE_BLOCK; }
bool isEmpty() override { return cachePath.empty() || !isCached; }
void finish() override {}
// Get scaled dimensions that fit within viewport
void getScaledDimensions(int viewportWidth, int viewportHeight, int* outWidth, int* outHeight) const;
};

View File

@ -1,11 +1,15 @@
#include "ChapterHtmlSlimParser.h" #include "ChapterHtmlSlimParser.h"
#include <Bitmap.h>
#include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <JpegToBmpConverter.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include <expat.h> #include <expat.h>
#include "../Page.h" #include "../Page.h"
#include "../blocks/ImageBlock.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"};
@ -65,7 +69,22 @@ 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 // Process image tag - extract src attribute
const char* src = nullptr;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "src") == 0) {
src = atts[i + 1];
break;
}
}
}
if (src && self->epub) {
self->processImage(src);
}
// Skip content inside image tag
self->skipUntilDepth = self->depth; self->skipUntilDepth = self->depth;
self->depth += 1; self->depth += 1;
return; return;
@ -330,3 +349,190 @@ void ChapterHtmlSlimParser::makePages() {
currentPageNextY += lineHeight / 2; currentPageNextY += lineHeight / 2;
} }
} }
std::string ChapterHtmlSlimParser::resolveImagePath(const char* src) const {
if (!src || !epub) {
return "";
}
std::string srcPath(src);
// If the path is absolute (starts with /), it's relative to the EPUB root
if (srcPath[0] == '/') {
return srcPath.substr(1); // Remove leading slash
}
// Otherwise, resolve relative to the current chapter's directory
std::string basePath = epub->getBasePath();
// Simple path resolution - combine basePath with src
if (basePath.empty()) {
return srcPath;
}
// Ensure basePath ends with /
if (basePath.back() != '/') {
basePath += '/';
}
return basePath + srcPath;
}
void ChapterHtmlSlimParser::processImage(const char* src) {
if (!src || !epub) {
Serial.printf("[%lu] [IMG] Invalid image source or epub pointer\n", millis());
return;
}
// Flush any pending text before adding image
if (currentTextBlock && !currentTextBlock->isEmpty()) {
makePages();
currentTextBlock.reset(new ParsedText(TextBlock::JUSTIFIED, extraParagraphSpacing));
}
// Resolve the image path relative to the EPUB structure
std::string imagePath = resolveImagePath(src);
if (imagePath.empty()) {
Serial.printf("[%lu] [IMG] Failed to resolve image path: %s\n", millis(), src);
return;
}
// Generate cache path for this image
std::string cachePath = epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + "_img_" +
std::to_string(imageCounter++) + ".bmp";
// Check if image is already cached
if (!SdMan.exists(cachePath.c_str())) {
// Determine file extension
bool isJpeg = false;
bool isBmp = false;
size_t dotPos = imagePath.rfind('.');
if (dotPos != std::string::npos) {
std::string ext = imagePath.substr(dotPos);
isJpeg = (ext == ".jpg" || ext == ".jpeg" || ext == ".JPG" || ext == ".JPEG");
isBmp = (ext == ".bmp" || ext == ".BMP");
}
if (isJpeg) {
// Extract JPEG and convert to BMP
std::string tempJpgPath = epub->getCachePath() + "/.temp_img.jpg";
FsFile tempJpg;
if (!SdMan.openFileForWrite("IMG", tempJpgPath.c_str(), tempJpg)) {
Serial.printf("[%lu] [IMG] Failed to create temp JPEG file\n", millis());
return;
}
if (!epub->readItemContentsToStream(imagePath, tempJpg, 1024)) {
Serial.printf("[%lu] [IMG] Failed to extract JPEG: %s\n", millis(), imagePath.c_str());
tempJpg.close();
SdMan.remove(tempJpgPath.c_str());
return;
}
tempJpg.close();
// Convert JPEG to BMP
if (!SdMan.openFileForRead("IMG", tempJpgPath.c_str(), tempJpg)) {
Serial.printf("[%lu] [IMG] Failed to reopen temp JPEG\n", millis());
SdMan.remove(tempJpgPath.c_str());
return;
}
FsFile bmpFile;
if (!SdMan.openFileForWrite("IMG", cachePath.c_str(), bmpFile)) {
Serial.printf("[%lu] [IMG] Failed to create BMP file\n", millis());
tempJpg.close();
SdMan.remove(tempJpgPath.c_str());
return;
}
bool success = JpegToBmpConverter::jpegFileToBmpStream(tempJpg, bmpFile);
tempJpg.close();
bmpFile.close();
SdMan.remove(tempJpgPath.c_str());
if (!success) {
Serial.printf("[%lu] [IMG] Failed to convert JPEG to BMP: %s\n", millis(), imagePath.c_str());
SdMan.remove(cachePath.c_str());
return;
}
} else if (isBmp) {
// Extract BMP directly
FsFile bmpFile;
if (!SdMan.openFileForWrite("IMG", cachePath.c_str(), bmpFile)) {
Serial.printf("[%lu] [IMG] Failed to create BMP file\n", millis());
return;
}
if (!epub->readItemContentsToStream(imagePath, bmpFile, 1024)) {
Serial.printf("[%lu] [IMG] Failed to extract BMP: %s\n", millis(), imagePath.c_str());
bmpFile.close();
SdMan.remove(cachePath.c_str());
return;
}
bmpFile.close();
} else {
// Unsupported format
Serial.printf("[%lu] [IMG] Unsupported image format (not JPEG or BMP): %s\n", millis(), imagePath.c_str());
return;
}
}
// Read image dimensions
FsFile imageFile;
if (!SdMan.openFileForRead("IMG", cachePath.c_str(), imageFile)) {
Serial.printf("[%lu] [IMG] Failed to open cached image: %s\n", millis(), cachePath.c_str());
return;
}
Bitmap bitmap(imageFile);
BmpReaderError err = bitmap.parseHeaders();
if (err != BmpReaderError::Ok) {
Serial.printf("[%lu] [IMG] Failed to parse BMP headers: %s\n", millis(), Bitmap::errorToString(err));
imageFile.close();
return;
}
int width = bitmap.getWidth();
int height = bitmap.getHeight();
imageFile.close();
// Add image to page
addImageToPage(cachePath, width, height);
Serial.printf("[%lu] [IMG] Successfully processed image: %s (%dx%d)\n", millis(), imagePath.c_str(), width, height);
}
void ChapterHtmlSlimParser::addImageToPage(const std::string& cachePath, int width, int height) {
// Calculate scaled dimensions to fit viewport
ImageBlock imageBlock(cachePath, width, height);
int scaledWidth, scaledHeight;
imageBlock.getScaledDimensions(viewportWidth, viewportHeight, &scaledWidth, &scaledHeight);
// Check if image fits on current page
if (currentPageNextY + scaledHeight > viewportHeight && currentPageNextY > 0) {
// Start new page for image
if (currentPage && !currentPage->elements.empty()) {
completePageFn(std::move(currentPage));
}
currentPage.reset(new Page());
currentPageNextY = 0;
}
if (!currentPage) {
currentPage.reset(new Page());
currentPageNextY = 0;
}
// Center image horizontally
int xPos = (viewportWidth - scaledWidth) / 2;
if (xPos < 0) xPos = 0;
// Add image to page
currentPage->elements.push_back(std::make_shared<PageImage>(cachePath, scaledWidth, scaledHeight, xPos, currentPageNextY));
currentPageNextY += scaledHeight;
// Add some spacing after image
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
currentPageNextY += lineHeight;
}

View File

@ -11,12 +11,16 @@
class Page; class Page;
class GfxRenderer; class GfxRenderer;
class Epub;
#define MAX_WORD_SIZE 200 #define MAX_WORD_SIZE 200
class ChapterHtmlSlimParser { class ChapterHtmlSlimParser {
const std::string& filepath; const std::string& filepath;
GfxRenderer& renderer; GfxRenderer& renderer;
Epub* epub; // Pointer to epub for image extraction
int spineIndex;
int imageCounter = 0; // Counter for naming cached images
std::function<void(std::unique_ptr<Page>)> completePageFn; std::function<void(std::unique_ptr<Page>)> completePageFn;
std::function<void(int)> progressFn; // Progress callback (0-100) std::function<void(int)> progressFn; // Progress callback (0-100)
int depth = 0; int depth = 0;
@ -38,19 +42,24 @@ class ChapterHtmlSlimParser {
void startNewTextBlock(TextBlock::BLOCK_STYLE style); void startNewTextBlock(TextBlock::BLOCK_STYLE style);
void makePages(); void makePages();
void processImage(const char* src);
void addImageToPage(const std::string& cachePath, int width, int height);
std::string resolveImagePath(const char* src) const;
// XML callbacks // XML callbacks
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void XMLCALL characterData(void* userData, const XML_Char* s, int len); static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
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(const std::string& filepath, GfxRenderer& renderer, Epub* epub, const int spineIndex,
const float lineCompression, const bool extraParagraphSpacing, const int viewportWidth, const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportHeight, const int viewportWidth, const int viewportHeight,
const std::function<void(std::unique_ptr<Page>)>& completePageFn, const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const std::function<void(int)>& progressFn = nullptr) const std::function<void(int)>& progressFn = nullptr)
: filepath(filepath), : filepath(filepath),
renderer(renderer), renderer(renderer),
epub(epub),
spineIndex(spineIndex),
fontId(fontId), fontId(fontId),
lineCompression(lineCompression), lineCompression(lineCompression),
extraParagraphSpacing(extraParagraphSpacing), extraParagraphSpacing(extraParagraphSpacing),

15
shell.nix Normal file
View File

@ -0,0 +1,15 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
platformio
python3
git
];
shellHook = ''
echo "PlatformIO development environment loaded"
echo "Run 'pio run' to build the firmware"
echo "Run 'pio run -t upload' to build and flash"
'';
}