diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..1d953f4b --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..73451a05 --- /dev/null +++ b/flake.nix @@ -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" + ''; + }; + } + ); +} diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 65ce5698..d238e9c7 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,50 @@ 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 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::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(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(TAG_PageLine)); + // Write element tag + serialization::writePod(file, static_cast(el->getTag())); + if (!el->serialize(file)) { return false; } @@ -59,6 +107,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..8f257e22 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -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 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 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 diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index b153f4f0..554a95a2 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -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 lut = {}; ChapterHtmlSlimParser visitor( - tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight, - [this, &lut](std::unique_ptr 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) { lut.emplace_back(this->onPageComplete(std::move(page))); }, progressFn); success = visitor.parseAndBuildPages(); diff --git a/lib/Epub/Epub/blocks/ImageBlock.cpp b/lib/Epub/Epub/blocks/ImageBlock.cpp new file mode 100644 index 00000000..3ba3f8fb --- /dev/null +++ b/lib/Epub/Epub/blocks/ImageBlock.cpp @@ -0,0 +1,25 @@ +#include "ImageBlock.h" + +#include + +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(viewportWidth) / static_cast(width); + float scaleY = static_cast(viewportHeight) / static_cast(height); + float scale = (scaleX < scaleY) ? scaleX : scaleY; + + *outWidth = static_cast(static_cast(width) * scale); + *outHeight = static_cast(static_cast(height) * scale); +} diff --git a/lib/Epub/Epub/blocks/ImageBlock.h b/lib/Epub/Epub/blocks/ImageBlock.h new file mode 100644 index 00000000..91d546ee --- /dev/null +++ b/lib/Epub/Epub/blocks/ImageBlock.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#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; +}; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 9f7fed9f..be7efc47 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1,11 +1,15 @@ #include "ChapterHtmlSlimParser.h" +#include +#include #include #include +#include #include #include #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(cachePath, scaledWidth, scaledHeight, xPos, currentPageNextY)); + currentPageNextY += scaledHeight; + + // Add some spacing after image + const int lineHeight = renderer.getLineHeight(fontId) * lineCompression; + currentPageNextY += lineHeight; +} diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 53bbbb4f..08265ae3 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -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)> completePageFn; std::function 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)>& completePageFn, const std::function& progressFn = nullptr) : filepath(filepath), renderer(renderer), + epub(epub), + spineIndex(spineIndex), fontId(fontId), lineCompression(lineCompression), extraParagraphSpacing(extraParagraphSpacing), diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..4300dd4b --- /dev/null +++ b/shell.nix @@ -0,0 +1,15 @@ +{ pkgs ? import {} }: + +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" + ''; +}