mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-06 23:57:39 +03:00
Merge 464f7a8189 into 21277e03eb
This commit is contained in:
commit
05f9cae9ab
@ -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
|
||||
|
||||
71
lib/BmpWriter/BmpWriter.h
Normal file
71
lib/BmpWriter/BmpWriter.h
Normal file
@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <Print.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// ============================================================================
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@ -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())) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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; });
|
||||
}
|
||||
};
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
#include <cstdint>
|
||||
|
||||
// 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
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
#include <cstring>
|
||||
|
||||
#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) {
|
||||
|
||||
273
lib/PngToBmpConverter/PngToBmpConverter.cpp
Normal file
273
lib/PngToBmpConverter/PngToBmpConverter.cpp
Normal file
@ -0,0 +1,273 @@
|
||||
#include "PngToBmpConverter.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <PNGdec.h>
|
||||
#include <SdFat.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#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<uint8_t>(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<PngDrawContext*>(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<uint8_t*>(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<uint8_t*>(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;
|
||||
}
|
||||
11
lib/PngToBmpConverter/PngToBmpConverter.h
Normal file
11
lib/PngToBmpConverter/PngToBmpConverter.h
Normal file
@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <Print.h>
|
||||
#include <SdFat.h>
|
||||
|
||||
class PngToBmpConverter {
|
||||
public:
|
||||
// Convert PNG file to 2-bit BMP stream
|
||||
// Similar API to JpegToBmpConverter for consistency
|
||||
static bool pngFileToBmpStream(FsFile& pngFile, Print& bmpOut);
|
||||
};
|
||||
@ -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]
|
||||
|
||||
@ -385,30 +385,34 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> 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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user