Compare commits

...

23 Commits

Author SHA1 Message Date
Dave Allie b1763821b5 Cut release 0.10.0 2025-12-29 02:30:27 +11:00
Dave Allie c0b83b626e Use a JSON filter to avoid crashes when checking for updates (#141)
## Summary

* The JSON release data from Github contains the entire release
description which can be very large
  * The 0.9.0 release was especially bad
* Use a JSON filter to avoid deserializing anything but the necessary
fields

## Additional Context

*
https://arduinojson.org/v7/how-to/deserialize-a-very-large-document/#filtering
* Fixes https://github.com/daveallie/crosspoint-reader/issues/124
2025-12-29 02:29:41 +11:00
Dave Allie f8c0b1acea Use confirmation release on home screen to detect action 2025-12-29 02:00:42 +11:00
Eunchurn Park f9b604f04e Add XTC/XTCH ebook format support (#135)
## Summary

* **What is the goal of this PR?**

Add support for XTC (XTeink X4 native) ebook format, which contains
pre-rendered 480x800 1-bit bitmap pages optimized for e-ink displays.

* **What changes are included?**

- New `lib/Xtc/` library with XtcParser for reading XTC files
- XtcReaderActivity for displaying XTC pages on e-ink display
- XTC file detection in FileSelectionActivity
- Cover BMP generation from first XTC page
- Correct XTG page header structure (22 bytes) and bit polarity handling

## Additional Context

- XTC files contain pre-rendered bitmap pages with embedded status bar
(page numbers, progress %)
- XTG page header: 22 bytes (magic + dimensions + reserved fields +
bitmap size)
- Bit polarity: 0 = black, 1 = white
- No runtime text rendering needed - pages display directly on e-ink
- Faster page display compared to EPUB since no parsing/rendering
required
- Memory efficient: loads one page at a time (48KB per page)
- Tested with XTC files generated from https://x4converter.rho.sh/
- Verified correct page alignment and color rendering
- Please report any issues if you test with XTC files from other
sources.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2025-12-29 01:56:05 +11:00
Dave Allie 3dc5f6fec4 Avoid jumping straight into chapter selection screen 2025-12-28 23:49:51 +11:00
Dave Allie 41c93e4eba Use font ascender height for baseline offset (#139)
## Summary

* Use font ascender height for baseline offset
* Previously was using font height, but when rendering the font (even
from y = 0), there would be a lot of top margin
* Font would also go below the "bottom of the line" as we were using the
full font height as the baseline

## Additional Context

* This caused some text to move around, I've fixed everything I can
* Notably it moves the first line of font a little closer to the top of
the page
2025-12-28 22:30:01 +11:00
Dave Allie 1c33162368 Fix rendering issue with entering keyboard from wifi screen 2025-12-28 21:50:45 +11:00
Dave Allie 27d42fbef3 Allow entering into chapter select screen correctly 2025-12-28 21:50:36 +11:00
Tannay dd280bdc97 Rotation Support (#77)
•  What is the goal of this PR?  
Implement a horizontal EPUB reading mode so books can be read in
landscape orientation (both 90° and 270°), while keeping the rest of the
UI in portrait.

•  What changes are included?
â—¦  Rendering / Display
â–Ş Added an orientation model to GfxRenderer (Portrait, LandscapeNormal,
LandscapeFlipped) and made:
â–Ş drawPixel, drawImage, displayWindow map logical coordinates
differently depending on orientation.
▪ getScreenWidth() / getScreenHeight() return orientation‑aware logical
dimensions (480Ă—800 in portrait, 800Ă—480 in landscape).
â—¦  Settings / Configuration
â–Ş  Extended CrossPointSettings with:
â–Ş  landscapeReading (toggle for portrait vs. landscape EPUB reading).
▪ landscapeFlipped (toggle to flip landscape 180° so both horizontal
holding directions are supported).
â–Ş Updated settings serialization/deserialization to persist these fields
while remaining backward‑compatible with existing settings files.
â–Ş  Updated SettingsActivity to expose two new toggles:
▪  “Landscape Reading”
▪  “Flip Landscape (swap top/bottom)”
â—¦  EPUB Reader
â–Ş  In EpubReaderActivity:
â–Ş On onEnter, set GfxRenderer orientation based on the new settings
(Portrait, LandscapeNormal, or LandscapeFlipped).
â–Ş On onExit, reset orientation back to Portrait so Home, WiFi, Settings,
etc. continue to render as before.
â–Ş Adjusted renderStatusBar to position the status bar and battery
indicator relative to GfxRenderer::getScreenHeight() instead of
hard‑coded Y coordinates, so it stays correctly at the bottom in both
portrait and landscape.
â—¦  EPUB Caching / Layout
â–Ş Extended Section cache metadata (section.bin) to include the logical
screenWidth and screenHeight used when pages were generated; bumped
SECTION_FILE_VERSION.
â–Ş  Updated loadCacheMetadata to compare:
â–Ş font/margins/line compression/extraParagraphSpacing and screen
dimensions; mismatches now invalidate and clear the cache.
â–Ş Updated persistPageDataToSD and all call sites in EpubReaderActivity
to pass the current GfxRenderer::getScreenWidth() / getScreenHeight() so
portrait and landscape caches are kept separate and correctly sized.



Additional Context

•  Cache behavior / migration
â—¦ Existing section.bin files (old SECTION_FILE_VERSION) will be detected
as incompatible and their caches cleared and rebuilt once per chapter
when first opened after this change.
â—¦ Within a given orientation, caches will be reused as before. Switching
orientation (portrait ↔ landscape) will cause a one‑time re‑index of
each chapter in the new orientation.
•  Scope and risks
â—¦ Orientation changes are scoped to the EPUB reader; the Home screen,
Settings, WiFi selection, sleep screens, and web server UI continue to
assume portrait orientation.
◦ The renderer’s orientation is a static/global setting; if future code
uses GfxRenderer outside the reader while a reader instance is active,
it should be aware that orientation is no longer implicitly fixed.
◦ All drawing primitives now go through orientation‑aware coordinate
transforms; any code that previously relied on edge‑case behavior or
out‑of‑bounds writes might surface as logged “Outside range” warnings
instead.
•  Testing suggestions / areas to focus on
â—¦  Verify in hardware:
â–Ş Portrait mode still renders correctly (boot, home, settings, WiFi,
reader).
â–Ş  Landscape reading in both directions:
â–Ş  Landscape Reading = ON, Flip Landscape = OFF.
â–Ş  Landscape Reading = ON, Flip Landscape = ON.
â–Ş Status bar (page X/Y, % progress, battery icon) is fully visible and
aligned at the bottom in all three combinations.
â—¦  Open the same book:
â–Ş  In portrait first, then switch to landscape and reopen it.
â–Ş  Confirm that:
â–Ş Old portrait caches are rebuilt once for landscape (you should see the
“Indexing…” page).
â–Ş Progress save/restore still works (resume opens to the correct page in
the current orientation).
â—¦ Ensure grayscale rendering (the secondary pass in
EpubReaderActivity::renderContents) still looks correct in both
orientations.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2025-12-28 21:33:20 +11:00
Dave Allie bf031fd999 Fix exiting WifiSelectionActivity renderer early 2025-12-28 19:27:00 +11:00
Dave Allie 02350c6a9f Fix underscore on keyboard and standardize activity (#138)
## Summary

* Fix underscore on keyboard
  * Remove special handling of special row characters
* Fix navigating between special row items
* Standardize keyboard activity to use standard loop
  * Fix issue with rendering keyboard non-stop

Fixes https://github.com/daveallie/crosspoint-reader/issues/131
2025-12-28 18:57:06 +11:00
Dave Allie 9023b262a1 Fix issue where pressing back from chapter select would leave book (#137)
## Summary

* Fix issue where pressing back from chapter select would leave book
* Rely on `wasReleased` checks instead
2025-12-28 17:06:18 +11:00
Eunchurn Park eabd149371 Add retry logic and progress bar for chapter indexing (#128)
## Summary

* **What is the goal of this PR?**

Improve reliability and user experience during chapter indexing by
adding retry logic for SD card operations and a visual progress bar.

* **What changes are included?**

- **Retry logic**: Add 3 retry attempts with 50ms delay for ZIP to SD
card streaming to handle timing issues after display refresh
- **Progress bar**: Display a visual progress bar (0-100%) during
chapter indexing based on file read progress, updating every 10% to
balance responsiveness with e-ink display limitations

## Additional Context

* **Problem observed**: When navigating quickly through books with many
chapters (before chapter titles finish rendering), the "Indexing..."
screen would appear frozen. Checking the serial log revealed the
operation had silently failed, but the UI showed no indication of this.
Users would likely assume the device had crashed. Pressing the next
button again would resume operation, but this behavior was confusing and
unexpected.

* **Solution**:
- Retry logic handles transient SD card timing failures automatically,
so users don't need to manually retry
- Progress bar provides visual feedback so users know indexing is
actively working (not frozen)

* **Why timing issues occur**: After display refresh operations, there
can be timing conflicts when immediately starting SD card write
operations. This is more likely to happen when rapidly navigating
through chapters.

* **Progress bar design**: Updates every 10% to avoid excessive e-ink
refreshes while still providing meaningful feedback during long indexing
operations (especially for large chapters with CJK characters).

* **Performance**: Minimal overhead - progress calculation is simple
byte counting, and display updates use `FAST_REFRESH` mode.
2025-12-28 15:59:44 +11:00
1991AcuraLegend 838246d147 Add setting to enable status bar display options (#111)
Add setting toggle that allows status bar display options in EpubReader.

Supported options would be as follows: 

- FULL: display as is today
- PROGRESS: display progress bar only
- BATTERY: display battery only
- NONE: hide status bar

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2025-12-28 10:48:27 +11:00
Eunchurn Park f96b6ab29c Improve EPUB cover image quality with pre-scaling and Atkinson dithering (#116)
## Summary

* **What is the goal of this PR?**

Replace simple threshold-based grayscale quantization with ordered
dithering using a 4x4 Bayer matrix. This eliminates color banding
artifacts and produces smoother gradients on e-ink display.

* **What changes are included?**

- Add 4x4 Bayer dithering matrix for 16-level threshold patterns
- Modify `grayscaleTo2Bit()` function to accept pixel coordinates and
apply position-based dithering
- Replace simple `grayscale >> 6` threshold with ordered dithering
algorithm that produces smoother gradients

## Additional Context

* Bayer matrix approach: The 4x4 Bayer matrix creates a repeating
pattern that distributes quantization error spatially, effectively
simulating 16 levels of gray using only 4 actual color levels (black,
dark gray, light gray, white).

* Cache invalidation: Existing cached `cover.bmp` files will need to be
deleted to see the improved rendering, as the converter only runs when
the cache is missing.
2025-12-28 10:38:14 +11:00
Brendan O'Leary e3d0201365 Add 'Open' button hint to File Selection page (#136)
## Summary

In using my build of
https://github.com/daveallie/crosspoint-reader/pull/130 I realized that
we need a "open" button hint above the second button in the File browser

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
2025-12-28 10:36:26 +11:00
Eunchurn Park 286b47f489 fix(parser): remove MAX_LINES limit that truncates long chapters (#132)
## Summary

* **What is the goal of this PR?** Fixes a bug where text disappears
after approximately 25 pages in long chapters during EPUB indexing.

* **What changes are included?**
- Removed the `MAX_LINES = 1000` hard limit in
`ParsedText::computeLineBreaks()`
- Added safer infinite loop prevention by checking if `nextBreakIndex <=
currentWordIndex` and forcing advancement by one word when stuck

## Additional Context

* **Root cause:** The `MAX_LINES = 1000` limit was introduced to prevent
infinite loops, but it truncates content in long chapters. For example,
a 93KB chapter that generates ~242 pages (~9,680 lines) gets cut off at
~1000 lines, causing blank pages after page 25-27.

* **Solution approach:** Instead of a hard line limit, I now detect when
the line break algorithm gets stuck (when `nextBreakIndex` doesn't
advance) and force progress by moving one word at a time. This preserves
the infinite loop protection while allowing all content to be rendered.

* **Testing:** Verified with a Korean EPUB containing a 93KB chapter -
all 242 pages now render correctly without text disappearing.
2025-12-28 10:35:45 +11:00
Dave Allie aff4dc6628 Fix QRCode import attempt 2 2025-12-26 11:33:41 +10:00
Dave Allie 98a39374e8 Fix QRCode import 2025-12-26 11:29:27 +10:00
Jonas Diemer e8c0fb42d4 Network details QR code (#113)
Using QRCode library from pio to generate the QR code.

Done:
- Display QR code for URL in network mode
- minor fixes of layout
- Display QR for URL in AP mode
- Display QR for AP in AP mode

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2025-12-26 12:13:40 +11:00
Eunchurn Park b77af16caa Add Continue Reading menu and remember last book folder (#129)
## Summary

* **What is the goal of this PR?**

Add a "Continue Reading" feature to improve user experience when
returning to a previously opened book.

* **What changes are included?**

- Add dynamic "Continue: <book name>" menu item in Home screen when a
book was previously opened

- File browser now starts from the folder of the last opened book
instead of always starting from root directory
- Menu dynamically shows 3 or 4 items based on reading history:
  - Without history: `Browse`, `File transfer`, `Settings`
- With history: `Continue: <book>`, `Browse`, `File transfer`,
`Settings`

## Additional Context

* This feature leverages the existing `APP_STATE.openEpubPath` which
already persists the last opened book path
* The Continue Reading menu only appears if the book file still exists
on the SD card
* Book name in the menu is truncated to 25 characters with "..." suffix
if too long
* If the last book's folder was deleted, the file browser gracefully
falls back to root directory
* No new dependencies or significant memory overhead - reuses existing
state management
2025-12-26 11:55:23 +11:00
Brendan O'Leary e3c1e28b8f Normalize button hints (#130)
## Summary

This creates a `renderer.drawButtonHints` to make all of the "hints"
over buttons to match the home screen.

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2025-12-26 11:54:02 +11:00
Eunchurn Park dc7544d944 Optimize glyph lookup with binary search (#125)
Replace linear O(n) search with binary search O(log n) for unicode
interval lookup. Korean fonts have many intervals (~30,000+ glyphs), so
this improves text rendering performance during page navigation.

## Summary

* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module, Implements the new feature for
  file uploading.)

Replace linear `O(n)` glyph lookup with binary search `O(log n)` to
improve text rendering performance during page navigation.

* **What changes are included?**

- Modified `EpdFont::getGlyph()` to use binary search instead of linear
search for unicode interval lookup
- Added early return for empty interval count

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).

- Performance implications: Fonts with many unicode intervals benefit
the most. Korean fonts have ~30,000+ glyphs across multiple intervals,
but any font with significant glyph coverage (CJK, extended Latin,
emoji, etc.) will see improvement.
- Complexity: from `O(n)` to `O(log n)` where n = number of unicode
intervals. For fonts with 10+ intervals, this reduces lookup iterations
significantly.
- Risk: Low - the binary search logic is straightforward and the
intervals are already sorted by unicode codepoint (required for the
original early-exit optimization).
2025-12-26 11:46:17 +11:00
49 changed files with 3300 additions and 614 deletions
-6
View File
@@ -12,12 +12,6 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/cache@v5
with:
path: |
~/.cache/pip
~/.platformio/.cache
key: ${{ runner.os }}-pio
- uses: actions/setup-python@v6
with:
python-version: '3.14'
+20 -6
View File
@@ -59,14 +59,28 @@ bool EpdFont::hasPrintableChars(const char* string) const {
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
const EpdUnicodeInterval* intervals = data->intervals;
for (int i = 0; i < data->intervalCount; i++) {
const EpdUnicodeInterval* interval = &intervals[i];
if (cp >= interval->first && cp <= interval->last) {
const int count = data->intervalCount;
if (count == 0) return nullptr;
// Binary search for O(log n) lookup instead of O(n)
// Critical for Korean fonts with many unicode intervals
int left = 0;
int right = count - 1;
while (left <= right) {
const int mid = left + (right - left) / 2;
const EpdUnicodeInterval* interval = &intervals[mid];
if (cp < interval->first) {
right = mid - 1;
} else if (cp > interval->last) {
left = mid + 1;
} else {
// Found: cp >= interval->first && cp <= interval->last
return &data->glyph[interval->offset + (cp - interval->first)];
}
if (cp < interval->first) {
return nullptr;
}
}
return nullptr;
}
+5 -3
View File
@@ -7,7 +7,9 @@ namespace {
constexpr uint8_t PAGE_FILE_VERSION = 3;
}
void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); }
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
block->render(renderer, fontId, xPos + xOffset, yPos + yOffset);
}
void PageLine::serialize(File& file) {
serialization::writePod(file, xPos);
@@ -27,9 +29,9 @@ std::unique_ptr<PageLine> PageLine::deserialize(File& file) {
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
}
void Page::render(GfxRenderer& renderer, const int fontId) const {
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
for (auto& element : elements) {
element->render(renderer, fontId);
element->render(renderer, fontId, xOffset, yOffset);
}
}
+3 -3
View File
@@ -17,7 +17,7 @@ class PageElement {
int16_t yPos;
explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {}
virtual ~PageElement() = default;
virtual void render(GfxRenderer& renderer, int fontId) = 0;
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
virtual void serialize(File& file) = 0;
};
@@ -28,7 +28,7 @@ class PageLine final : public PageElement {
public:
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), block(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId) override;
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
void serialize(File& file) override;
static std::unique_ptr<PageLine> deserialize(File& file);
};
@@ -37,7 +37,7 @@ class Page {
public:
// the list of block index and line numbers on this page
std::vector<std::shared_ptr<PageElement>> elements;
void render(GfxRenderer& renderer, int fontId) const;
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
void serialize(File& file) const;
static std::unique_ptr<Page> deserialize(File& file);
};
+20 -7
View File
@@ -18,14 +18,14 @@ void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) {
}
// Consumes data to minimize memory usage
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int horizontalMargin,
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
const bool includeLastLine) {
if (words.empty()) {
return;
}
const int pageWidth = renderer.getScreenWidth() - horizontalMargin;
const int pageWidth = viewportWidth;
const int spaceWidth = renderer.getSpaceWidth(fontId);
const auto wordWidths = calculateWordWidths(renderer, fontId);
const auto lineBreakIndices = computeLineBreaks(pageWidth, spaceWidth, wordWidths);
@@ -106,21 +106,34 @@ std::vector<size_t> ParsedText::computeLineBreaks(const int pageWidth, const int
ans[i] = j; // j is the index of the last word in this optimal line
}
}
// Handle oversized word: if no valid configuration found, force single-word line
// This prevents cascade failure where one oversized word breaks all preceding words
if (dp[i] == MAX_COST) {
ans[i] = i; // Just this word on its own line
// Inherit cost from next word to allow subsequent words to find valid configurations
if (i + 1 < static_cast<int>(totalWordCount)) {
dp[i] = dp[i + 1];
} else {
dp[i] = 0;
}
}
}
// Stores the index of the word that starts the next line (last_word_index + 1)
std::vector<size_t> lineBreakIndices;
size_t currentWordIndex = 0;
constexpr size_t MAX_LINES = 1000;
while (currentWordIndex < totalWordCount) {
if (lineBreakIndices.size() >= MAX_LINES) {
break;
size_t nextBreakIndex = ans[currentWordIndex] + 1;
// Safety check: prevent infinite loop if nextBreakIndex doesn't advance
if (nextBreakIndex <= currentWordIndex) {
// Force advance by at least one word to avoid infinite loop
nextBreakIndex = currentWordIndex + 1;
}
size_t nextBreakIndex = ans[currentWordIndex] + 1;
lineBreakIndices.push_back(nextBreakIndex);
currentWordIndex = nextBreakIndex;
}
+1 -1
View File
@@ -34,7 +34,7 @@ class ParsedText {
TextBlock::BLOCK_STYLE getStyle() const { return style; }
size_t size() const { return words.size(); }
bool isEmpty() const { return words.empty(); }
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int horizontalMargin,
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
bool includeLastLine = true);
};
+58 -34
View File
@@ -8,8 +8,8 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 5;
}
constexpr uint8_t SECTION_FILE_VERSION = 6;
} // namespace
void Section::onPageComplete(std::unique_ptr<Page> page) {
const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin";
@@ -26,9 +26,8 @@ void Section::onPageComplete(std::unique_ptr<Page> page) {
pageCount++;
}
void Section::writeCacheMetadata(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing) const {
void Section::writeCacheMetadata(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight) const {
File outputFile;
if (!FsHelpers::openFileForWrite("SCT", cachePath + "/section.bin", outputFile)) {
return;
@@ -36,18 +35,15 @@ void Section::writeCacheMetadata(const int fontId, const float lineCompression,
serialization::writePod(outputFile, SECTION_FILE_VERSION);
serialization::writePod(outputFile, fontId);
serialization::writePod(outputFile, lineCompression);
serialization::writePod(outputFile, marginTop);
serialization::writePod(outputFile, marginRight);
serialization::writePod(outputFile, marginBottom);
serialization::writePod(outputFile, marginLeft);
serialization::writePod(outputFile, extraParagraphSpacing);
serialization::writePod(outputFile, viewportWidth);
serialization::writePod(outputFile, viewportHeight);
serialization::writePod(outputFile, pageCount);
outputFile.close();
}
bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing) {
bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight) {
const auto sectionFilePath = cachePath + "/section.bin";
File inputFile;
if (!FsHelpers::openFileForRead("SCT", sectionFilePath, inputFile)) {
@@ -65,20 +61,18 @@ bool Section::loadCacheMetadata(const int fontId, const float lineCompression, c
return false;
}
int fileFontId, fileMarginTop, fileMarginRight, fileMarginBottom, fileMarginLeft;
int fileFontId, fileViewportWidth, fileViewportHeight;
float fileLineCompression;
bool fileExtraParagraphSpacing;
serialization::readPod(inputFile, fileFontId);
serialization::readPod(inputFile, fileLineCompression);
serialization::readPod(inputFile, fileMarginTop);
serialization::readPod(inputFile, fileMarginRight);
serialization::readPod(inputFile, fileMarginBottom);
serialization::readPod(inputFile, fileMarginLeft);
serialization::readPod(inputFile, fileExtraParagraphSpacing);
serialization::readPod(inputFile, fileViewportWidth);
serialization::readPod(inputFile, fileViewportHeight);
if (fontId != fileFontId || lineCompression != fileLineCompression || marginTop != fileMarginTop ||
marginRight != fileMarginRight || marginBottom != fileMarginBottom || marginLeft != fileMarginLeft ||
extraParagraphSpacing != fileExtraParagraphSpacing) {
if (fontId != fileFontId || lineCompression != fileLineCompression ||
extraParagraphSpacing != fileExtraParagraphSpacing || viewportWidth != fileViewportWidth ||
viewportHeight != fileViewportHeight) {
inputFile.close();
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis());
clearCache();
@@ -113,28 +107,58 @@ bool Section::clearCache() const {
return true;
}
bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing) {
bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight,
const std::function<void()>& progressSetupFn,
const std::function<void(int)>& progressFn) {
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
File tmpHtml;
if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
return false;
// Retry logic for SD card timing issues
bool success = false;
size_t fileSize = 0;
for (int attempt = 0; attempt < 3 && !success; attempt++) {
if (attempt > 0) {
Serial.printf("[%lu] [SCT] Retrying stream (attempt %d)...\n", millis(), attempt + 1);
delay(50); // Brief delay before retry
}
// Remove any incomplete file from previous attempt before retrying
if (SD.exists(tmpHtmlPath.c_str())) {
SD.remove(tmpHtmlPath.c_str());
}
File tmpHtml;
if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
continue;
}
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
fileSize = tmpHtml.size();
tmpHtml.close();
// If streaming failed, remove the incomplete file immediately
if (!success && SD.exists(tmpHtmlPath.c_str())) {
SD.remove(tmpHtmlPath.c_str());
Serial.printf("[%lu] [SCT] Removed incomplete temp file after failed attempt\n", millis());
}
}
bool success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
tmpHtml.close();
if (!success) {
Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file\n", millis());
Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file after retries\n", millis());
return false;
}
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str());
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize);
ChapterHtmlSlimParser visitor(tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom,
marginLeft, extraParagraphSpacing,
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); });
// Only show progress bar for larger chapters where rendering overhead is worth it
if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) {
progressSetupFn();
}
ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight,
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); }, progressFn);
success = visitor.parseAndBuildPages();
SD.remove(tmpHtmlPath.c_str());
@@ -143,7 +167,7 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
return false;
}
writeCacheMetadata(fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, extraParagraphSpacing);
writeCacheMetadata(fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight);
return true;
}
+8 -6
View File
@@ -1,4 +1,5 @@
#pragma once
#include <functional>
#include <memory>
#include "Epub.h"
@@ -12,8 +13,8 @@ class Section {
GfxRenderer& renderer;
std::string cachePath;
void writeCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom,
int marginLeft, bool extraParagraphSpacing) const;
void writeCacheMetadata(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int viewportHeight) const;
void onPageComplete(std::unique_ptr<Page> page);
public:
@@ -26,11 +27,12 @@ class Section {
renderer(renderer),
cachePath(epub->getCachePath() + "/" + std::to_string(spineIndex)) {}
~Section() = default;
bool loadCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom,
int marginLeft, bool extraParagraphSpacing);
bool loadCacheMetadata(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int viewportHeight);
void setupCacheDir() const;
bool clearCache() const;
bool persistPageDataToSD(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom,
int marginLeft, bool extraParagraphSpacing);
bool persistPageDataToSD(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int viewportHeight, const std::function<void()>& progressSetupFn = nullptr,
const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSD() const;
};
+22 -1
View File
@@ -4,11 +4,18 @@
#include <Serialization.h>
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
// Validate iterator bounds before rendering
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
Serial.printf("[%lu] [TXB] Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(),
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
return;
}
auto wordIt = words.begin();
auto wordStylesIt = wordStyles.begin();
auto wordXposIt = wordXpos.begin();
for (int i = 0; i < words.size(); i++) {
for (size_t i = 0; i < words.size(); i++) {
renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt);
std::advance(wordIt, 1);
@@ -46,6 +53,13 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(File& file) {
// words
serialization::readPod(file, wc);
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
if (wc > 10000) {
Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc);
return nullptr;
}
words.resize(wc);
for (auto& w : words) serialization::readString(file, w);
@@ -59,6 +73,13 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(File& file) {
wordStyles.resize(sc);
for (auto& s : wordStyles) serialization::readPod(file, s);
// Validate data consistency: all three lists must have the same size
if (wc != xc || wc != sc) {
Serial.printf("[%lu] [TXB] Deserialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), wc,
xc, sc);
return nullptr;
}
// style
serialization::readPod(file, style);
@@ -11,6 +11,9 @@
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
// Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
@@ -152,7 +155,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
if (self->currentTextBlock->size() > 750) {
Serial.printf("[%lu] [EHP] Text block too long, splitting into multiple pages\n", millis());
self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, self->marginLeft + self->marginRight,
self->renderer, self->fontId, self->viewportWidth,
[self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false);
}
}
@@ -221,6 +224,11 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false;
}
// Get file size for progress calculation
const size_t totalSize = file.size();
size_t bytesRead = 0;
int lastProgress = -1;
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
@@ -249,6 +257,17 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false;
}
// Update progress (call every 10% change to avoid too frequent updates)
// Only show progress for larger chapters where rendering overhead is worth it
bytesRead += len;
if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) {
const int progress = static_cast<int>((bytesRead * 100) / totalSize);
if (lastProgress / 10 != progress / 10) {
lastProgress = progress;
progressFn(progress);
}
}
done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
@@ -282,15 +301,14 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
const int pageHeight = GfxRenderer::getScreenHeight() - marginTop - marginBottom;
if (currentPageNextY + lineHeight > pageHeight) {
if (currentPageNextY + lineHeight > viewportHeight) {
completePageFn(std::move(currentPage));
currentPage.reset(new Page());
currentPageNextY = marginTop;
currentPageNextY = 0;
}
currentPage->elements.push_back(std::make_shared<PageLine>(line, marginLeft, currentPageNextY));
currentPage->elements.push_back(std::make_shared<PageLine>(line, 0, currentPageNextY));
currentPageNextY += lineHeight;
}
@@ -302,12 +320,12 @@ void ChapterHtmlSlimParser::makePages() {
if (!currentPage) {
currentPage.reset(new Page());
currentPageNextY = marginTop;
currentPageNextY = 0;
}
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
currentTextBlock->layoutAndExtractLines(
renderer, fontId, marginLeft + marginRight,
renderer, fontId, viewportWidth,
[this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); });
// Extra paragraph spacing if enabled
if (extraParagraphSpacing) {
+11 -12
View File
@@ -18,6 +18,7 @@ class ChapterHtmlSlimParser {
const std::string& filepath;
GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn;
std::function<void(int)> progressFn; // Progress callback (0-100)
int depth = 0;
int skipUntilDepth = INT_MAX;
int boldUntilDepth = INT_MAX;
@@ -31,11 +32,9 @@ class ChapterHtmlSlimParser {
int16_t currentPageNextY = 0;
int fontId;
float lineCompression;
int marginTop;
int marginRight;
int marginBottom;
int marginLeft;
bool extraParagraphSpacing;
int viewportWidth;
int viewportHeight;
void startNewTextBlock(TextBlock::BLOCK_STYLE style);
void makePages();
@@ -46,19 +45,19 @@ class ChapterHtmlSlimParser {
public:
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
const float lineCompression, const int marginTop, const int marginRight,
const int marginBottom, const int marginLeft, const bool extraParagraphSpacing,
const std::function<void(std::unique_ptr<Page>)>& completePageFn)
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),
fontId(fontId),
lineCompression(lineCompression),
marginTop(marginTop),
marginRight(marginRight),
marginBottom(marginBottom),
marginLeft(marginLeft),
extraParagraphSpacing(extraParagraphSpacing),
completePageFn(completePageFn) {}
viewportWidth(viewportWidth),
viewportHeight(viewportHeight),
completePageFn(completePageFn),
progressFn(progressFn) {}
~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line);
+174 -2
View File
@@ -3,6 +3,126 @@
#include <cstdlib>
#include <cstring>
// ============================================================================
// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations
// ============================================================================
// Note: For cover images, dithering is done in JpegToBmpConverter.cpp
// This file handles BMP reading - use simple quantization to avoid double-dithering
constexpr bool USE_FLOYD_STEINBERG = false; // Disabled - dithering done at JPEG conversion
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering
// Brightness adjustments:
constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments
constexpr int BRIGHTNESS_BOOST = 20; // Brightness offset (0-50), only if USE_BRIGHTNESS=true
constexpr bool GAMMA_CORRECTION = false; // Gamma curve, only if USE_BRIGHTNESS=true
// ============================================================================
// Integer approximation of gamma correction (brightens midtones)
static inline int applyGamma(int gray) {
if (!GAMMA_CORRECTION) return gray;
const int product = gray * 255;
int x = gray;
if (x > 0) {
x = (x + product / x) >> 1;
x = (x + product / x) >> 1;
}
return x > 255 ? 255 : x;
}
// Simple quantization without dithering - just divide into 4 levels
static inline uint8_t quantizeSimple(int gray) {
if (USE_BRIGHTNESS) {
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
gray = applyGamma(gray);
}
return static_cast<uint8_t>(gray >> 6);
}
// Hash-based noise dithering - survives downsampling without moiré artifacts
static inline uint8_t quantizeNoise(int gray, int x, int y) {
if (USE_BRIGHTNESS) {
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
gray = applyGamma(gray);
}
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24);
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Main quantization function
static inline uint8_t quantize(int gray, int x, int y) {
if (USE_NOISE_DITHERING) {
return quantizeNoise(gray, x, y);
} else {
return quantizeSimple(gray);
}
}
// Floyd-Steinberg quantization with error diffusion and serpentine scanning
// Returns 2-bit value (0-3) and updates error buffers
static inline uint8_t quantizeFloydSteinberg(int gray, int x, int width, int16_t* errorCurRow, int16_t* errorNextRow,
bool reverseDir) {
// Add accumulated error to this pixel
int adjusted = gray + errorCurRow[x + 1];
// Clamp to valid range
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels (0, 85, 170, 255)
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error
int error = adjusted - quantizedValue;
// Distribute error to neighbors (serpentine: direction-aware)
if (!reverseDir) {
// Left to right
errorCurRow[x + 2] += (error * 7) >> 4; // Right: 7/16
errorNextRow[x] += (error * 3) >> 4; // Bottom-left: 3/16
errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16
errorNextRow[x + 2] += (error) >> 4; // Bottom-right: 1/16
} else {
// Right to left (mirrored)
errorCurRow[x] += (error * 7) >> 4; // Left: 7/16
errorNextRow[x + 2] += (error * 3) >> 4; // Bottom-right: 3/16
errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16
errorNextRow[x] += (error) >> 4; // Bottom-left: 1/16
}
return quantized;
}
Bitmap::~Bitmap() {
delete[] errorCurRow;
delete[] errorNextRow;
}
uint16_t Bitmap::readLE16(File& f) {
const int c0 = f.read();
const int c1 = f.read();
@@ -46,6 +166,8 @@ const char* Bitmap::errorToString(BmpReaderError err) {
return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)";
case BmpReaderError::BadDimensions:
return "BadDimensions";
case BmpReaderError::ImageTooLarge:
return "ImageTooLarge (max 2048x3072)";
case BmpReaderError::PaletteTooLarge:
return "PaletteTooLarge";
@@ -99,6 +221,13 @@ BmpReaderError Bitmap::parseHeaders() {
if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions;
// Safety limits to prevent memory issues on ESP32
constexpr int MAX_IMAGE_WIDTH = 2048;
constexpr int MAX_IMAGE_HEIGHT = 3072;
if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) {
return BmpReaderError::ImageTooLarge;
}
// Pre-calculate Row Bytes to avoid doing this every row
rowBytes = (width * bpp + 31) / 32 * 4;
@@ -115,21 +244,56 @@ BmpReaderError Bitmap::parseHeaders() {
return BmpReaderError::SeekPixelDataFailed;
}
// Allocate Floyd-Steinberg error buffers if enabled
if (USE_FLOYD_STEINBERG) {
delete[] errorCurRow;
delete[] errorNextRow;
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling
errorNextRow = new int16_t[width + 2]();
lastRowY = -1;
}
return BmpReaderError::Ok;
}
// packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white
BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const {
BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const {
// Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes'
if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow;
// Handle Floyd-Steinberg error buffer progression
const bool useFS = USE_FLOYD_STEINBERG && errorCurRow && errorNextRow;
if (useFS) {
// Check if we need to advance to next row (or reset if jumping)
if (rowY != lastRowY + 1 && rowY != 0) {
// Non-sequential row access - reset error buffers
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
} else if (rowY > 0) {
// Sequential access - swap buffers
int16_t* temp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = temp;
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
}
lastRowY = rowY;
}
uint8_t* outPtr = data;
uint8_t currentOutByte = 0;
int bitShift = 6;
int currentX = 0;
// Helper lambda to pack 2bpp color into the output stream
auto packPixel = [&](const uint8_t lum) {
uint8_t color = (lum >> 6); // Simple 2-bit reduction: 0-255 -> 0-3
uint8_t color;
if (useFS) {
// Floyd-Steinberg error diffusion
color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false);
} else {
// Simple quantization or noise dithering
color = quantize(lum, currentX, rowY);
}
currentOutByte |= (color << bitShift);
if (bitShift == 0) {
*outPtr++ = currentOutByte;
@@ -138,6 +302,7 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const {
} else {
bitShift -= 2;
}
currentX++;
};
uint8_t lum;
@@ -196,5 +361,12 @@ BmpReaderError Bitmap::rewindToData() const {
return BmpReaderError::SeekPixelDataFailed;
}
// Reset Floyd-Steinberg error buffers when rewinding
if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
lastRowY = -1;
}
return BmpReaderError::Ok;
}
+8 -1
View File
@@ -15,6 +15,7 @@ enum class BmpReaderError : uint8_t {
UnsupportedCompression,
BadDimensions,
ImageTooLarge,
PaletteTooLarge,
SeekPixelDataFailed,
@@ -28,8 +29,9 @@ class Bitmap {
static const char* errorToString(BmpReaderError err);
explicit Bitmap(File& file) : file(file) {}
~Bitmap();
BmpReaderError parseHeaders();
BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer) const;
BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const;
BmpReaderError rewindToData() const;
int getWidth() const { return width; }
int getHeight() const { return height; }
@@ -49,4 +51,9 @@ class Bitmap {
uint16_t bpp = 0;
int rowBytes = 0;
uint8_t paletteLum[256] = {};
// Floyd-Steinberg dithering state (mutable for const methods)
mutable int16_t* errorCurRow = nullptr;
mutable int16_t* errorNextRow = nullptr;
mutable int lastRowY = -1; // Track row progression for error propagation
};
+148 -29
View File
@@ -4,6 +4,37 @@
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); }
void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int* rotatedY) const {
switch (orientation) {
case Portrait: {
// Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees clockwise
*rotatedX = y;
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x;
break;
}
case LandscapeClockwise: {
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - x;
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - y;
break;
}
case PortraitInverted: {
// Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees counter-clockwise
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - y;
*rotatedY = x;
break;
}
case LandscapeCounterClockwise: {
// Logical landscape (800x480) aligned with panel orientation
*rotatedX = x;
*rotatedY = y;
break;
}
}
}
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
@@ -13,15 +44,14 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
return;
}
// Rotate coordinates: portrait (480x800) -> landscape (800x480)
// Rotation: 90 degrees clockwise
const int rotatedX = y;
const int rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x;
int rotatedX = 0;
int rotatedY = 0;
rotateCoordinates(x, y, &rotatedX, &rotatedY);
// Bounds checking (portrait: 480x800)
// Bounds checking against physical panel dimensions
if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 ||
rotatedY >= EInkDisplay::DISPLAY_HEIGHT) {
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d)\n", millis(), x, y);
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY);
return;
}
@@ -55,7 +85,7 @@ void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* te
void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontStyle style) const {
const int yPos = y + getLineHeight(fontId);
const int yPos = y + getFontAscenderSize(fontId);
int xpos = x;
// cannot draw a NULL / empty string
@@ -115,8 +145,11 @@ void GfxRenderer::fillRect(const int x, const int y, const int width, const int
}
void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
// Flip X and Y for portrait mode
einkDisplay.drawImage(bitmap, y, x, height, width);
// TODO: Rotate bits
int rotatedX = 0;
int rotatedY = 0;
rotateCoordinates(x, y, &rotatedX, &rotatedY);
einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height);
}
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
@@ -132,7 +165,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
isScaled = true;
}
const uint8_t outputRowSize = (bitmap.getWidth() + 3) / 4;
// Calculate output row size (2 bits per pixel, packed into bytes)
// IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
@@ -154,7 +189,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
break;
}
if (bitmap.readRow(outputRow, rowBytes) != BmpReaderError::Ok) {
if (bitmap.readRow(outputRow, rowBytes, bmpY) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY);
free(outputRow);
free(rowBytes);
@@ -203,23 +238,34 @@ void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) cons
einkDisplay.displayBuffer(refreshMode);
}
void GfxRenderer::displayWindow(const int x, const int y, const int width, const int height) const {
// Rotate coordinates from portrait (480x800) to landscape (800x480)
// Rotation: 90 degrees clockwise
// Portrait coordinates: (x, y) with dimensions (width, height)
// Landscape coordinates: (rotatedX, rotatedY) with dimensions (rotatedWidth, rotatedHeight)
const int rotatedX = y;
const int rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x - width + 1;
const int rotatedWidth = height;
const int rotatedHeight = width;
einkDisplay.displayWindow(rotatedX, rotatedY, rotatedWidth, rotatedHeight);
// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation
int GfxRenderer::getScreenWidth() const {
switch (orientation) {
case Portrait:
case PortraitInverted:
// 480px wide in portrait logical coordinates
return EInkDisplay::DISPLAY_HEIGHT;
case LandscapeClockwise:
case LandscapeCounterClockwise:
// 800px wide in landscape logical coordinates
return EInkDisplay::DISPLAY_WIDTH;
}
return EInkDisplay::DISPLAY_HEIGHT;
}
// Note: Internal driver treats screen in command orientation, this library treats in portrait orientation
int GfxRenderer::getScreenWidth() { return EInkDisplay::DISPLAY_HEIGHT; }
int GfxRenderer::getScreenHeight() { return EInkDisplay::DISPLAY_WIDTH; }
int GfxRenderer::getScreenHeight() const {
switch (orientation) {
case Portrait:
case PortraitInverted:
// 800px tall in portrait logical coordinates
return EInkDisplay::DISPLAY_WIDTH;
case LandscapeClockwise:
case LandscapeCounterClockwise:
// 480px tall in landscape logical coordinates
return EInkDisplay::DISPLAY_HEIGHT;
}
return EInkDisplay::DISPLAY_WIDTH;
}
int GfxRenderer::getSpaceWidth(const int fontId) const {
if (fontMap.count(fontId) == 0) {
@@ -230,6 +276,15 @@ int GfxRenderer::getSpaceWidth(const int fontId) const {
return fontMap.at(fontId).getGlyph(' ', REGULAR)->advanceX;
}
int GfxRenderer::getFontAscenderSize(const int fontId) const {
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return 0;
}
return fontMap.at(fontId).getData(REGULAR)->ascender;
}
int GfxRenderer::getLineHeight(const int fontId) const {
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
@@ -239,6 +294,28 @@ int GfxRenderer::getLineHeight(const int fontId) const {
return fontMap.at(fontId).getData(REGULAR)->advanceY;
}
void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const {
const int pageHeight = getScreenHeight();
constexpr int buttonWidth = 106;
constexpr int buttonHeight = 40;
constexpr int buttonY = 40; // Distance from bottom
constexpr int textYOffset = 7; // Distance from top of button to text baseline
constexpr int buttonPositions[] = {25, 130, 245, 350};
const char* labels[] = {btn1, btn2, btn3, btn4};
for (int i = 0; i < 4; i++) {
// Only draw if the label is non-empty
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int x = buttonPositions[i];
drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight);
const int textWidth = getTextWidth(fontId, labels[i]);
const int textX = x + (buttonWidth - 1 - textWidth) / 2;
drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]);
}
}
}
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
@@ -264,12 +341,13 @@ void GfxRenderer::freeBwBufferChunks() {
* This should be called before grayscale buffers are populated.
* A `restoreBwBuffer` call should always follow the grayscale render if this method was called.
* Uses chunked allocation to avoid needing 48KB of contiguous memory.
* Returns true if buffer was stored successfully, false if allocation failed.
*/
void GfxRenderer::storeBwBuffer() {
bool GfxRenderer::storeBwBuffer() {
const uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
if (!frameBuffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis());
return;
return false;
}
// Allocate and copy each chunk
@@ -290,7 +368,7 @@ void GfxRenderer::storeBwBuffer() {
BW_BUFFER_CHUNK_SIZE);
// Free previously allocated chunks
freeBwBufferChunks();
return;
return false;
}
memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE);
@@ -298,6 +376,7 @@ void GfxRenderer::storeBwBuffer() {
Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n", millis(), BW_BUFFER_NUM_CHUNKS,
BW_BUFFER_CHUNK_SIZE);
return true;
}
/**
@@ -345,6 +424,17 @@ void GfxRenderer::restoreBwBuffer() {
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis());
}
/**
* Cleanup grayscale buffers using the current frame buffer.
* Use this when BW buffer was re-rendered instead of stored/restored.
*/
void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
if (frameBuffer) {
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
}
}
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y,
const bool pixelState, const EpdFontStyle style) const {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
@@ -408,3 +498,32 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp,
*x += glyph->advanceX;
}
void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const {
switch (orientation) {
case Portrait:
*outTop = VIEWABLE_MARGIN_TOP;
*outRight = VIEWABLE_MARGIN_RIGHT;
*outBottom = VIEWABLE_MARGIN_BOTTOM;
*outLeft = VIEWABLE_MARGIN_LEFT;
break;
case LandscapeClockwise:
*outTop = VIEWABLE_MARGIN_LEFT;
*outRight = VIEWABLE_MARGIN_TOP;
*outBottom = VIEWABLE_MARGIN_RIGHT;
*outLeft = VIEWABLE_MARGIN_BOTTOM;
break;
case PortraitInverted:
*outTop = VIEWABLE_MARGIN_BOTTOM;
*outRight = VIEWABLE_MARGIN_LEFT;
*outBottom = VIEWABLE_MARGIN_TOP;
*outLeft = VIEWABLE_MARGIN_RIGHT;
break;
case LandscapeCounterClockwise:
*outTop = VIEWABLE_MARGIN_RIGHT;
*outRight = VIEWABLE_MARGIN_BOTTOM;
*outBottom = VIEWABLE_MARGIN_LEFT;
*outLeft = VIEWABLE_MARGIN_TOP;
break;
}
}
+30 -5
View File
@@ -12,6 +12,14 @@ class GfxRenderer {
public:
enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB };
// Logical screen orientation from the perspective of callers
enum Orientation {
Portrait, // 480x800 logical coordinates (current default)
LandscapeClockwise, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
PortraitInverted, // 480x800 logical coordinates, inverted
LandscapeCounterClockwise // 800x480 logical coordinates, native panel orientation
};
private:
static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory
static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
@@ -20,24 +28,35 @@ class GfxRenderer {
EInkDisplay& einkDisplay;
RenderMode renderMode;
Orientation orientation;
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
std::map<int, EpdFontFamily> fontMap;
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState,
EpdFontStyle style) const;
void freeBwBufferChunks();
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
public:
explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW) {}
explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {}
~GfxRenderer() = default;
static constexpr int VIEWABLE_MARGIN_TOP = 9;
static constexpr int VIEWABLE_MARGIN_RIGHT = 3;
static constexpr int VIEWABLE_MARGIN_BOTTOM = 3;
static constexpr int VIEWABLE_MARGIN_LEFT = 3;
// Setup
void insertFont(int fontId, EpdFontFamily font);
// Orientation control (affects logical width/height and coordinate transforms)
void setOrientation(const Orientation o) { orientation = o; }
Orientation getOrientation() const { return orientation; }
// Screen ops
static int getScreenWidth();
static int getScreenHeight();
int getScreenWidth() const;
int getScreenHeight() const;
void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const;
// EXPERIMENTAL: Windowed update - display only a rectangular region (portrait coordinates)
// EXPERIMENTAL: Windowed update - display only a rectangular region
void displayWindow(int x, int y, int width, int height) const;
void invertScreen() const;
void clearScreen(uint8_t color = 0xFF) const;
@@ -55,18 +74,24 @@ class GfxRenderer {
void drawCenteredText(int fontId, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const;
void drawText(int fontId, int x, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const;
int getSpaceWidth(int fontId) const;
int getFontAscenderSize(int fontId) const;
int getLineHeight(int fontId) const;
// UI Components
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const;
// Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
void copyGrayscaleLsbBuffers() const;
void copyGrayscaleMsbBuffers() const;
void displayGrayBuffer() const;
void storeBwBuffer();
bool storeBwBuffer(); // Returns true if buffer was stored successfully
void restoreBwBuffer();
void cleanupGrayscaleWithFrameBuffer() const;
// Low level functions
uint8_t* getFrameBuffer() const;
static size_t getBufferSize();
void grayscaleRevert() const;
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
};
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@ class ZipFile;
class JpegToBmpConverter {
static void writeBmpHeader(Print& bmpOut, int width, int height);
static uint8_t grayscaleTo2Bit(uint8_t grayscale);
// [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y);
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
+40
View File
@@ -0,0 +1,40 @@
# XTC/XTCH Library
XTC ebook format support for CrossPoint Reader.
## Supported Formats
| Format | Extension | Description |
|--------|-----------|----------------------------------------------|
| XTC | `.xtc` | Container with XTG pages (1-bit monochrome) |
| XTCH | `.xtch` | Container with XTH pages (2-bit grayscale) |
## Format Overview
XTC/XTCH are container formats designed for ESP32 e-paper displays. They store pre-rendered bitmap pages optimized for the XTeink X4 e-reader (480x800 resolution).
### Container Structure (XTC/XTCH)
- 56-byte header with metadata offsets
- Optional metadata (title, author, etc.)
- Page index table (16 bytes per page)
- Page data (XTG or XTH format)
### Page Formats
#### XTG (1-bit monochrome)
- Row-major storage, 8 pixels per byte
- MSB first (bit 7 = leftmost pixel)
- 0 = Black, 1 = White
#### XTH (2-bit grayscale)
- Two bit planes stored sequentially
- Column-major order (right to left)
- 8 vertical pixels per byte
- Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black
## Reference
Original format info: <https://gist.github.com/CrazyCoder/b125f26d6987c0620058249f59f1327d>
+337
View File
@@ -0,0 +1,337 @@
/**
* Xtc.cpp
*
* Main XTC ebook class implementation
* XTC ebook support for CrossPoint Reader
*/
#include "Xtc.h"
#include <FsHelpers.h>
#include <HardwareSerial.h>
#include <SD.h>
bool Xtc::load() {
Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str());
// Initialize parser
parser.reset(new xtc::XtcParser());
// Open XTC file
xtc::XtcError err = parser->open(filepath.c_str());
if (err != xtc::XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to load: %s\n", millis(), xtc::errorToString(err));
parser.reset();
return false;
}
loaded = true;
Serial.printf("[%lu] [XTC] Loaded XTC: %s (%lu pages)\n", millis(), filepath.c_str(), parser->getPageCount());
return true;
}
bool Xtc::clearCache() const {
if (!SD.exists(cachePath.c_str())) {
Serial.printf("[%lu] [XTC] Cache does not exist, no action needed\n", millis());
return true;
}
if (!FsHelpers::removeDir(cachePath.c_str())) {
Serial.printf("[%lu] [XTC] Failed to clear cache\n", millis());
return false;
}
Serial.printf("[%lu] [XTC] Cache cleared successfully\n", millis());
return true;
}
void Xtc::setupCacheDir() const {
if (SD.exists(cachePath.c_str())) {
return;
}
// Create directories recursively
for (size_t i = 1; i < cachePath.length(); i++) {
if (cachePath[i] == '/') {
SD.mkdir(cachePath.substr(0, i).c_str());
}
}
SD.mkdir(cachePath.c_str());
}
std::string Xtc::getTitle() const {
if (!loaded || !parser) {
return "";
}
// Try to get title from XTC metadata first
std::string title = parser->getTitle();
if (!title.empty()) {
return title;
}
// Fallback: extract filename from path as title
size_t lastSlash = filepath.find_last_of('/');
size_t lastDot = filepath.find_last_of('.');
if (lastSlash == std::string::npos) {
lastSlash = 0;
} else {
lastSlash++;
}
if (lastDot == std::string::npos || lastDot <= lastSlash) {
return filepath.substr(lastSlash);
}
return filepath.substr(lastSlash, lastDot - lastSlash);
}
std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
bool Xtc::generateCoverBmp() const {
// Already generated
if (SD.exists(getCoverBmpPath().c_str())) {
return true;
}
if (!loaded || !parser) {
Serial.printf("[%lu] [XTC] Cannot generate cover BMP, file not loaded\n", millis());
return false;
}
if (parser->getPageCount() == 0) {
Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis());
return false;
}
// Setup cache directory
setupCacheDir();
// Get first page info for cover
xtc::PageInfo pageInfo;
if (!parser->getPageInfo(0, pageInfo)) {
Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis());
return false;
}
// Get bit depth
const uint8_t bitDepth = parser->getBitDepth();
// Allocate buffer for page data
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
// XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes
size_t bitmapSize;
if (bitDepth == 2) {
bitmapSize = ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) * 2;
} else {
bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height;
}
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
if (!pageBuffer) {
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize);
return false;
}
// Load first page (cover)
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
if (bytesRead == 0) {
Serial.printf("[%lu] [XTC] Failed to load cover page\n", millis());
free(pageBuffer);
return false;
}
// Create BMP file
File coverBmp;
if (!FsHelpers::openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) {
Serial.printf("[%lu] [XTC] Failed to create cover BMP file\n", millis());
free(pageBuffer);
return false;
}
// Write BMP header
// BMP file header (14 bytes)
const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4; // Row size aligned to 4 bytes
const uint32_t imageSize = rowSize * pageInfo.height;
const uint32_t fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data
// File header
coverBmp.write('B');
coverBmp.write('M');
coverBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
uint32_t reserved = 0;
coverBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes)
coverBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
// DIB header (BITMAPINFOHEADER - 40 bytes)
uint32_t dibHeaderSize = 40;
coverBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
int32_t width = pageInfo.width;
coverBmp.write(reinterpret_cast<const uint8_t*>(&width), 4);
int32_t height = -static_cast<int32_t>(pageInfo.height); // Negative for top-down
coverBmp.write(reinterpret_cast<const uint8_t*>(&height), 4);
uint16_t planes = 1;
coverBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
uint16_t bitsPerPixel = 1; // 1-bit monochrome
coverBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
uint32_t compression = 0; // BI_RGB (no compression)
coverBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
coverBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
int32_t ppmX = 2835; // 72 DPI
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
int32_t ppmY = 2835;
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
uint32_t colorsUsed = 2;
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
uint32_t colorsImportant = 2;
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
// Color palette (2 colors for 1-bit)
// XTC uses inverted polarity: 0 = black, 1 = white
// Color 0: Black (text/foreground in XTC)
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00};
coverBmp.write(black, 4);
// Color 1: White (background in XTC)
uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00};
coverBmp.write(white, 4);
// Write bitmap data
// BMP requires 4-byte row alignment
const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size
if (bitDepth == 2) {
// XTH 2-bit mode: Two bit planes, column-major order
// - Columns scanned right to left (x = width-1 down to 0)
// - 8 vertical pixels per byte (MSB = topmost pixel in group)
// - First plane: Bit1, Second plane: Bit2
// - Pixel value = (bit1 << 1) | bit2
const size_t planeSize = (static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8;
const uint8_t* plane1 = pageBuffer; // Bit1 plane
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
const size_t colBytes = (pageInfo.height + 7) / 8; // Bytes per column
// Allocate a row buffer for 1-bit output
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(dstRowSize));
if (!rowBuffer) {
free(pageBuffer);
coverBmp.close();
return false;
}
for (uint16_t y = 0; y < pageInfo.height; y++) {
memset(rowBuffer, 0xFF, dstRowSize); // Start with all white
for (uint16_t x = 0; x < pageInfo.width; x++) {
// Column-major, right to left: column index = (width - 1 - x)
const size_t colIndex = pageInfo.width - 1 - x;
const size_t byteInCol = y / 8;
const size_t bitInByte = 7 - (y % 8); // MSB = topmost pixel
const size_t byteOffset = colIndex * colBytes + byteInCol;
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
const uint8_t pixelValue = (bit1 << 1) | bit2;
// Threshold: 0=white (1); 1,2,3=black (0)
if (pixelValue >= 1) {
// Set bit to 0 (black) in BMP format
const size_t dstByte = x / 8;
const size_t dstBit = 7 - (x % 8);
rowBuffer[dstByte] &= ~(1 << dstBit);
}
}
// Write converted row
coverBmp.write(rowBuffer, dstRowSize);
// Pad to 4-byte boundary
uint8_t padding[4] = {0, 0, 0, 0};
size_t paddingSize = rowSize - dstRowSize;
if (paddingSize > 0) {
coverBmp.write(padding, paddingSize);
}
}
free(rowBuffer);
} else {
// 1-bit source: write directly with proper padding
const size_t srcRowSize = (pageInfo.width + 7) / 8;
for (uint16_t y = 0; y < pageInfo.height; y++) {
// Write source row
coverBmp.write(pageBuffer + y * srcRowSize, srcRowSize);
// Pad to 4-byte boundary
uint8_t padding[4] = {0, 0, 0, 0};
size_t paddingSize = rowSize - srcRowSize;
if (paddingSize > 0) {
coverBmp.write(padding, paddingSize);
}
}
}
coverBmp.close();
free(pageBuffer);
Serial.printf("[%lu] [XTC] Generated cover BMP: %s\n", millis(), getCoverBmpPath().c_str());
return true;
}
uint32_t Xtc::getPageCount() const {
if (!loaded || !parser) {
return 0;
}
return parser->getPageCount();
}
uint16_t Xtc::getPageWidth() const {
if (!loaded || !parser) {
return 0;
}
return parser->getWidth();
}
uint16_t Xtc::getPageHeight() const {
if (!loaded || !parser) {
return 0;
}
return parser->getHeight();
}
uint8_t Xtc::getBitDepth() const {
if (!loaded || !parser) {
return 1; // Default to 1-bit
}
return parser->getBitDepth();
}
size_t Xtc::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const {
if (!loaded || !parser) {
return 0;
}
return const_cast<xtc::XtcParser*>(parser.get())->loadPage(pageIndex, buffer, bufferSize);
}
xtc::XtcError Xtc::loadPageStreaming(uint32_t pageIndex,
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
size_t chunkSize) const {
if (!loaded || !parser) {
return xtc::XtcError::FILE_NOT_FOUND;
}
return const_cast<xtc::XtcParser*>(parser.get())->loadPageStreaming(pageIndex, callback, chunkSize);
}
uint8_t Xtc::calculateProgress(uint32_t currentPage) const {
if (!loaded || !parser || parser->getPageCount() == 0) {
return 0;
}
return static_cast<uint8_t>((currentPage + 1) * 100 / parser->getPageCount());
}
xtc::XtcError Xtc::getLastError() const {
if (!parser) {
return xtc::XtcError::FILE_NOT_FOUND;
}
return parser->getLastError();
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Xtc.h
*
* Main XTC ebook class for CrossPoint Reader
* Provides EPUB-like interface for XTC file handling
*/
#pragma once
#include <memory>
#include <string>
#include "Xtc/XtcParser.h"
#include "Xtc/XtcTypes.h"
/**
* XTC Ebook Handler
*
* Handles XTC file loading, page access, and cover image generation.
* Interface is designed to be similar to Epub class for easy integration.
*/
class Xtc {
std::string filepath;
std::string cachePath;
std::unique_ptr<xtc::XtcParser> parser;
bool loaded;
public:
explicit Xtc(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)), loaded(false) {
// Create cache key based on filepath (same as Epub)
cachePath = cacheDir + "/xtc_" + std::to_string(std::hash<std::string>{}(this->filepath));
}
~Xtc() = default;
/**
* Load XTC file
* @return true on success
*/
bool load();
/**
* Clear cached data
* @return true on success
*/
bool clearCache() const;
/**
* Setup cache directory
*/
void setupCacheDir() const;
// Path accessors
const std::string& getCachePath() const { return cachePath; }
const std::string& getPath() const { return filepath; }
// Metadata
std::string getTitle() const;
// Cover image support (for sleep screen)
std::string getCoverBmpPath() const;
bool generateCoverBmp() const;
// Page access
uint32_t getPageCount() const;
uint16_t getPageWidth() const;
uint16_t getPageHeight() const;
uint8_t getBitDepth() const; // 1 = XTC (1-bit), 2 = XTCH (2-bit)
/**
* Load page bitmap data
* @param pageIndex Page index (0-based)
* @param buffer Output buffer
* @param bufferSize Buffer size
* @return Number of bytes read
*/
size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const;
/**
* Load page with streaming callback
* @param pageIndex Page index
* @param callback Callback for each chunk
* @param chunkSize Chunk size
* @return Error code
*/
xtc::XtcError loadPageStreaming(uint32_t pageIndex,
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
size_t chunkSize = 1024) const;
// Progress calculation
uint8_t calculateProgress(uint32_t currentPage) const;
// Check if file is loaded
bool isLoaded() const { return loaded; }
// Error information
xtc::XtcError getLastError() const;
};

Some files were not shown because too many files have changed in this diff Show More