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 <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,50 @@ 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 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 {
for (auto& element : elements) {
element->render(renderer, fontId, xOffset, yOffset);
@ -36,8 +83,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));
// Write element tag
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
if (!el->serialize(file)) {
return false;
}
@ -59,6 +107,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

@ -8,6 +8,7 @@
enum PageElementTag : uint8_t {
TAG_PageLine = 1,
TAG_PageImage = 2,
};
// represents something that has been added to a page
@ -19,6 +20,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 +32,27 @@ 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
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 {
public:
// the list of block index and line numbers on this page

View File

@ -7,7 +7,7 @@
#include "parsers/ChapterHtmlSlimParser.h"
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) +
sizeof(int) + sizeof(int) + sizeof(uint32_t);
} // namespace
@ -169,8 +169,8 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
std::vector<uint32_t> lut = {};
ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
tmpHtmlPath, renderer, epub.get(), spineIndex, fontId, lineCompression, extraParagraphSpacing, viewportWidth,
viewportHeight, [this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
progressFn);
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 <Bitmap.h>
#include <Epub.h>
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <JpegToBmpConverter.h>
#include <SDCardManager.h>
#include <expat.h>
#include "../Page.h"
#include "../blocks/ImageBlock.h"
#include "../htmlEntities.h"
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)) {
// 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->depth += 1;
return;
@ -330,3 +349,190 @@ void ChapterHtmlSlimParser::makePages() {
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 GfxRenderer;
class Epub;
#define MAX_WORD_SIZE 200
class ChapterHtmlSlimParser {
const std::string& filepath;
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(int)> progressFn; // Progress callback (0-100)
int depth = 0;
@ -38,19 +42,24 @@ class ChapterHtmlSlimParser {
void startNewTextBlock(TextBlock::BLOCK_STYLE style);
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
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 endElement(void* userData, const XML_Char* name);
public:
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
const float lineCompression, const bool extraParagraphSpacing, const int viewportWidth,
const int viewportHeight,
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, Epub* epub, const int spineIndex,
const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight,
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const std::function<void(int)>& progressFn = nullptr)
: filepath(filepath),
renderer(renderer),
epub(epub),
spineIndex(spineIndex),
fontId(fontId),
lineCompression(lineCompression),
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"
'';
}