Compare commits

...

2 Commits

Author SHA1 Message Date
Eunchurn Park
30c50ef45b
perf(xtc): optimize XTCH grayscale rendering and memory usage
- Remove storeBwBuffer() requirement for grayscale rendering
  - Peak memory reduced from 144KB to 96KB
  - Use 4-pass rendering: BW display → LSB → MSB → grayscale → BW restore

- Optimize PageInfo struct (24→16 bytes)
  - Change offset from uint64_t to uint32_t (4GB file limit)
  - Saves ~15KB for large books (e.g., 1883 pages: 45KB → 30KB)

- Add storeBwBuffer() return value for error handling
- Add pixel distribution logging for debugging
- Fix LSB/MSB grayscale mapping per README spec
2025-12-28 18:04:48 +09:00
Eunchurn Park
4ef35afb4d
feat(xtc): add XTCH format support (2-bit grayscale)
- Add XTCH container format support (.xtch extension)
- Add XTH page format with two bit-plane, column-major storage
- Update XtcParser to handle both XTC/XTCH magic and XTG/XTH pages
- Update XtcReaderActivity for XTH column-major rendering
- Update cover BMP generation for 2-bit grayscale
- Add README with format documentation

XTH format details:
- Two bit planes stored sequentially (Bit1, then Bit2)
- Column-major order, scanned right to left
- 8 vertical pixels per byte (MSB = topmost)
- Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black
2025-12-28 12:40:34 +09:00
12 changed files with 368 additions and 86 deletions

View File

@ -286,12 +286,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
@ -312,7 +313,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);
@ -320,6 +321,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;
}
/**

View File

@ -65,7 +65,7 @@ class GfxRenderer {
void copyGrayscaleLsbBuffers() const;
void copyGrayscaleMsbBuffers() const;
void displayGrayBuffer() const;
void storeBwBuffer();
bool storeBwBuffer(); // Returns true if buffer was stored successfully
void restoreBwBuffer();
// Low level functions

40
lib/Xtc/README Normal file
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>

View File

@ -115,8 +115,18 @@ bool Xtc::generateCoverBmp() const {
return false;
}
// Allocate buffer for page data (XTC is always 1-bit monochrome)
const size_t bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height;
// 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);
@ -187,19 +197,77 @@ bool Xtc::generateCoverBmp() const {
coverBmp.write(white, 4);
// Write bitmap data
// XTC stores data as packed bits, same as BMP 1-bit format
// But we need to ensure proper row alignment (4-byte boundary)
const size_t srcRowSize = (pageInfo.width + 7) / 8; // Source row size
// BMP requires 4-byte row alignment
const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size
for (uint16_t y = 0; y < pageInfo.height; y++) {
// Write source row
coverBmp.write(pageBuffer + y * srcRowSize, srcRowSize);
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
// 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);
// 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);
}
}
}
@ -231,6 +299,13 @@ uint16_t Xtc::getPageHeight() const {
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;

View File

@ -64,6 +64,7 @@ class Xtc {
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

View File

@ -14,7 +14,11 @@
namespace xtc {
XtcParser::XtcParser()
: m_isOpen(false), m_defaultWidth(DISPLAY_WIDTH), m_defaultHeight(DISPLAY_HEIGHT), m_lastError(XtcError::OK) {
: m_isOpen(false),
m_defaultWidth(DISPLAY_WIDTH),
m_defaultHeight(DISPLAY_HEIGHT),
m_bitDepth(1),
m_lastError(XtcError::OK) {
memset(&m_header, 0, sizeof(m_header));
}
@ -76,12 +80,16 @@ XtcError XtcParser::readHeader() {
return XtcError::READ_ERROR;
}
// Verify magic number
if (m_header.magic != XTC_MAGIC) {
Serial.printf("[%lu] [XTC] Invalid magic: 0x%08X (expected 0x%08X)\n", millis(), m_header.magic, XTC_MAGIC);
// Verify magic number (accept both XTC and XTCH)
if (m_header.magic != XTC_MAGIC && m_header.magic != XTCH_MAGIC) {
Serial.printf("[%lu] [XTC] Invalid magic: 0x%08X (expected 0x%08X or 0x%08X)\n", millis(), m_header.magic,
XTC_MAGIC, XTCH_MAGIC);
return XtcError::INVALID_MAGIC;
}
// Determine bit depth from file magic
m_bitDepth = (m_header.magic == XTCH_MAGIC) ? 2 : 1;
// Check version
if (m_header.version > 1) {
Serial.printf("[%lu] [XTC] Unsupported version: %d\n", millis(), m_header.version);
@ -93,8 +101,8 @@ XtcError XtcParser::readHeader() {
return XtcError::CORRUPTED_HEADER;
}
Serial.printf("[%lu] [XTC] Header: magic=0x%08X, ver=%u, pages=%u, pageTableOff=%llu, dataOff=%llu\n", millis(),
m_header.magic, m_header.version, m_header.pageCount, m_header.pageTableOffset, m_header.dataOffset);
Serial.printf("[%lu] [XTC] Header: magic=0x%08X (%s), ver=%u, pages=%u, bitDepth=%u\n", millis(), m_header.magic,
(m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.version, m_header.pageCount, m_bitDepth);
return XtcError::OK;
}
@ -141,10 +149,11 @@ XtcError XtcParser::readPageTable() {
return XtcError::READ_ERROR;
}
m_pageTable[i].offset = entry.dataOffset;
m_pageTable[i].offset = static_cast<uint32_t>(entry.dataOffset);
m_pageTable[i].size = entry.dataSize;
m_pageTable[i].width = entry.width;
m_pageTable[i].height = entry.height;
m_pageTable[i].bitDepth = m_bitDepth;
// Update default dimensions from first page
if (i == 0) {
@ -180,29 +189,39 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
// Seek to page data
if (!m_file.seek(page.offset)) {
Serial.printf("[%lu] [XTC] Failed to seek to page %u at offset %llu\n", millis(), pageIndex, page.offset);
Serial.printf("[%lu] [XTC] Failed to seek to page %u at offset %lu\n", millis(), pageIndex, page.offset);
m_lastError = XtcError::READ_ERROR;
return 0;
}
// Read XTG header first
XtgPageHeader xtgHeader;
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&xtgHeader), sizeof(XtgPageHeader));
// Read page header (XTG for 1-bit, XTH for 2-bit - same structure)
XtgPageHeader pageHeader;
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
if (headerRead != sizeof(XtgPageHeader)) {
Serial.printf("[%lu] [XTC] Failed to read XTG header for page %u\n", millis(), pageIndex);
Serial.printf("[%lu] [XTC] Failed to read page header for page %u\n", millis(), pageIndex);
m_lastError = XtcError::READ_ERROR;
return 0;
}
// Verify XTG magic
if (xtgHeader.magic != XTG_MAGIC) {
Serial.printf("[%lu] [XTC] Invalid XTG magic for page %u: 0x%08X\n", millis(), pageIndex, xtgHeader.magic);
// Verify page magic (XTG for 1-bit, XTH for 2-bit)
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
if (pageHeader.magic != expectedMagic) {
Serial.printf("[%lu] [XTC] Invalid page magic for page %u: 0x%08X (expected 0x%08X)\n", millis(), pageIndex,
pageHeader.magic, expectedMagic);
m_lastError = XtcError::INVALID_MAGIC;
return 0;
}
// Calculate bitmap size
const size_t bitmapSize = ((xtgHeader.width + 7) / 8) * xtgHeader.height;
// Calculate bitmap size based on bit depth
// 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 (m_bitDepth == 2) {
// XTH: two bit planes, each containing (width * height) bits rounded up to bytes
bitmapSize = ((static_cast<size_t>(pageHeader.width) * pageHeader.height + 7) / 8) * 2;
} else {
bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height;
}
// Check buffer size
if (bufferSize < bitmapSize) {
@ -241,15 +260,23 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
return XtcError::READ_ERROR;
}
// Read and skip XTG header
XtgPageHeader xtgHeader;
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&xtgHeader), sizeof(XtgPageHeader));
if (headerRead != sizeof(XtgPageHeader) || xtgHeader.magic != XTG_MAGIC) {
// Read and skip page header (XTG for 1-bit, XTH for 2-bit)
XtgPageHeader pageHeader;
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
if (headerRead != sizeof(XtgPageHeader) || pageHeader.magic != expectedMagic) {
return XtcError::READ_ERROR;
}
// Calculate bitmap size
const size_t bitmapSize = ((xtgHeader.width + 7) / 8) * xtgHeader.height;
// Calculate bitmap size based on bit depth
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
// XTH (2-bit): Two bit planes, ((width * height + 7) / 8) * 2 bytes
size_t bitmapSize;
if (m_bitDepth == 2) {
bitmapSize = ((static_cast<size_t>(pageHeader.width) * pageHeader.height + 7) / 8) * 2;
} else {
bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height;
}
// Read in chunks
std::vector<uint8_t> chunk(chunkSize);
@ -284,7 +311,7 @@ bool XtcParser::isValidXtcFile(const char* filepath) {
return false;
}
return (magic == XTC_MAGIC);
return (magic == XTC_MAGIC || magic == XTCH_MAGIC);
}
} // namespace xtc

View File

@ -39,6 +39,7 @@ class XtcParser {
uint16_t getPageCount() const { return m_header.pageCount; }
uint16_t getWidth() const { return m_defaultWidth; }
uint16_t getHeight() const { return m_defaultHeight; }
uint8_t getBitDepth() const { return m_bitDepth; } // 1 = XTC/XTG, 2 = XTCH/XTH
// Page information
bool getPageInfo(uint32_t pageIndex, PageInfo& info) const;
@ -83,6 +84,7 @@ class XtcParser {
std::string m_title;
uint16_t m_defaultWidth;
uint16_t m_defaultHeight;
uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit)
XtcError m_lastError;
// Internal helper functions

View File

@ -18,9 +18,13 @@ namespace xtc {
// XTC file magic numbers (little-endian)
// "XTC\0" = 0x58, 0x54, 0x43, 0x00
constexpr uint32_t XTC_MAGIC = 0x00435458; // "XTC\0" in little-endian
constexpr uint32_t XTC_MAGIC = 0x00435458; // "XTC\0" in little-endian (1-bit fast mode)
// "XTCH" = 0x58, 0x54, 0x43, 0x48
constexpr uint32_t XTCH_MAGIC = 0x48435458; // "XTCH" in little-endian (2-bit high quality mode)
// "XTG\0" = 0x58, 0x54, 0x47, 0x00
constexpr uint32_t XTG_MAGIC = 0x00475458; // "XTG\0" for page data
constexpr uint32_t XTG_MAGIC = 0x00475458; // "XTG\0" for 1-bit page data
// "XTH\0" = 0x58, 0x54, 0x48, 0x00
constexpr uint32_t XTH_MAGIC = 0x00485458; // "XTH\0" for 2-bit page data
// XTeink X4 display resolution
constexpr uint16_t DISPLAY_WIDTH = 480;
@ -54,27 +58,39 @@ struct PageTableEntry {
};
#pragma pack(pop)
// XTG page data header (22 bytes)
// XTG/XTH page data header (22 bytes)
// Used for both 1-bit (XTG) and 2-bit (XTH) formats
#pragma pack(push, 1)
struct XtgPageHeader {
uint32_t magic; // 0x00: Magic "XTG\0" (0x00475458)
uint16_t width; // 0x04: Bitmap width
uint16_t height; // 0x06: Bitmap height
uint16_t reserved1; // 0x08: Reserved (0)
uint32_t bitmapSize; // 0x0A: Bitmap data size = ((width+7)/8) * height
uint32_t reserved2; // 0x0E: Reserved (0)
uint32_t reserved3; // 0x12: Reserved (0)
uint32_t magic; // 0x00: File identifier (XTG: 0x00475458, XTH: 0x00485458)
uint16_t width; // 0x04: Image width (pixels)
uint16_t height; // 0x06: Image height (pixels)
uint8_t colorMode; // 0x08: Color mode (0=monochrome)
uint8_t compression; // 0x09: Compression (0=uncompressed)
uint32_t dataSize; // 0x0A: Image data size (bytes)
uint64_t md5; // 0x0E: MD5 checksum (first 8 bytes, optional)
// Followed by bitmap data at offset 0x16 (22)
//
// XTG (1-bit): Row-major, 8 pixels/byte, MSB first
// dataSize = ((width + 7) / 8) * height
//
// XTH (2-bit): Two bit planes, column-major (right-to-left), 8 vertical pixels/byte
// dataSize = ((width * height + 7) / 8) * 2
// First plane: Bit1 for all pixels
// Second plane: Bit2 for all pixels
// pixelValue = (bit1 << 1) | bit2
};
#pragma pack(pop)
// Page information (internal use)
// Page information (internal use, optimized for memory)
struct PageInfo {
uint64_t offset; // File offset to page data
uint32_t size; // Data size (bytes)
uint16_t width; // Page width
uint16_t height; // Page height
};
uint32_t offset; // File offset to page data (max 4GB file size)
uint32_t size; // Data size (bytes)
uint16_t width; // Page width
uint16_t height; // Page height
uint8_t bitDepth; // 1 = XTG (1-bit), 2 = XTH (2-bit grayscale)
uint8_t padding; // Alignment padding
}; // 16 bytes total
// Error codes
enum class XtcError {
@ -119,13 +135,13 @@ inline const char* errorToString(XtcError err) {
}
/**
* Check if filename has XTC extension
* Check if filename has XTC/XTCH extension
*/
inline bool isXtcExtension(const char* filename) {
if (!filename) return false;
const char* ext = strrchr(filename, '.');
if (!ext) return false;
return (strcasecmp(ext, ".xtc") == 0 || strcasecmp(ext, ".xtg") == 0 || strcasecmp(ext, ".xth") == 0);
return (strcasecmp(ext, ".xtc") == 0 || strcasecmp(ext, ".xtch") == 0);
}
} // namespace xtc

View File

@ -14,11 +14,16 @@
#include "images/CrossLarge.h"
namespace {
// Check if path has XTC extension
// Check if path has XTC extension (.xtc or .xtch)
bool isXtcFile(const std::string& path) {
if (path.length() < 4) return false;
std::string ext = path.substr(path.length() - 4);
return (ext == ".xtc" || ext == ".xtg" || ext == ".xth");
std::string ext4 = path.substr(path.length() - 4);
if (ext4 == ".xtc") return true;
if (path.length() >= 5) {
std::string ext5 = path.substr(path.length() - 5);
if (ext5 == ".xtch") return true;
}
return false;
}
} // namespace

View File

@ -43,10 +43,15 @@ void FileSelectionActivity::loadFiles() {
} else if (filename.length() >= 5 && filename.substr(filename.length() - 5) == ".epub") {
files.emplace_back(filename);
} else if (filename.length() >= 4) {
// Check for XTC format extensions (.xtc, .xtg, .xth)
std::string ext = filename.substr(filename.length() - 4);
if (ext == ".xtc" || ext == ".xtg" || ext == ".xth") {
// Check for XTC format extensions (.xtc, .xtch)
std::string ext4 = filename.substr(filename.length() - 4);
if (ext4 == ".xtc") {
files.emplace_back(filename);
} else if (filename.length() >= 5) {
std::string ext5 = filename.substr(filename.length() - 5);
if (ext5 == ".xtch") {
files.emplace_back(filename);
}
}
}
file.close();

View File

@ -19,8 +19,13 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
bool ReaderActivity::isXtcFile(const std::string& path) {
if (path.length() < 4) return false;
std::string ext = path.substr(path.length() - 4);
return (ext == ".xtc" || ext == ".xtg" || ext == ".xth");
std::string ext4 = path.substr(path.length() - 4);
if (ext4 == ".xtc") return true;
if (path.length() >= 5) {
std::string ext5 = path.substr(path.length() - 5);
if (ext5 == ".xtch") return true;
}
return false;
}
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {

View File

@ -150,9 +150,17 @@ void XtcReaderActivity::renderScreen() {
void XtcReaderActivity::renderPage() {
const uint16_t pageWidth = xtc->getPageWidth();
const uint16_t pageHeight = xtc->getPageHeight();
const uint8_t bitDepth = xtc->getBitDepth();
// Calculate buffer size for one page (XTC is always 1-bit monochrome)
const size_t pageBufferSize = ((pageWidth + 7) / 8) * pageHeight;
// Calculate buffer size for one page
// 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 pageBufferSize;
if (bitDepth == 2) {
pageBufferSize = ((static_cast<size_t>(pageWidth) * pageHeight + 7) / 8) * 2;
} else {
pageBufferSize = ((pageWidth + 7) / 8) * pageHeight;
}
// Allocate page buffer
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize));
@ -179,29 +187,124 @@ void XtcReaderActivity::renderPage() {
renderer.clearScreen();
// Copy page bitmap using GfxRenderer's drawPixel
// XTC stores 1-bit packed data in portrait (480x800) format
const size_t srcRowBytes = (pageWidth + 7) / 8; // 60 bytes for 480 width
// XTC pages are pre-rendered with status bar included, so render full page
// XTC/XTCH pages are pre-rendered with status bar included, so render full page
const uint16_t maxSrcY = pageHeight;
for (uint16_t srcY = 0; srcY < maxSrcY; srcY++) {
const size_t srcRowStart = srcY * srcRowBytes;
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
// - Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black
for (uint16_t srcX = 0; srcX < pageWidth; srcX++) {
// Read source pixel (MSB first, bit 7 = leftmost pixel)
const size_t srcByte = srcRowStart + srcX / 8;
const size_t srcBit = 7 - (srcX % 8);
const bool isBlack = !((pageBuffer[srcByte] >> srcBit) & 1); // XTC: 0 = black, 1 = white
const size_t planeSize = (static_cast<size_t>(pageWidth) * pageHeight + 7) / 8;
const uint8_t* plane1 = pageBuffer; // Bit1 plane
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
const size_t colBytes = (pageHeight + 7) / 8; // Bytes per column (100 for 800 height)
// Use GfxRenderer's drawPixel with logical portrait coordinates
// drawPixel(x, y, state) where state=true draws black
if (isBlack) {
renderer.drawPixel(srcX, srcY, true);
// Lambda to get pixel value at (x, y)
auto getPixelValue = [&](uint16_t x, uint16_t y) -> uint8_t {
const size_t colIndex = pageWidth - 1 - x;
const size_t byteInCol = y / 8;
const size_t bitInByte = 7 - (y % 8);
const size_t byteOffset = colIndex * colBytes + byteInCol;
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
return (bit1 << 1) | bit2;
};
// Optimized grayscale rendering without storeBwBuffer (saves 48KB peak memory)
// Flow: BW display → LSB/MSB passes → grayscale display → re-render BW for next frame
// Count pixel distribution for debugging
uint32_t pixelCounts[4] = {0, 0, 0, 0};
for (uint16_t y = 0; y < pageHeight; y++) {
for (uint16_t x = 0; x < pageWidth; x++) {
pixelCounts[getPixelValue(x, y)]++;
}
}
Serial.printf("[%lu] [XTR] Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu\n", millis(),
pixelCounts[0], pixelCounts[1], pixelCounts[2], pixelCounts[3]);
// Pass 1: BW buffer - draw all non-white pixels as black
for (uint16_t y = 0; y < pageHeight; y++) {
for (uint16_t x = 0; x < pageWidth; x++) {
if (getPixelValue(x, y) >= 1) {
renderer.drawPixel(x, y, true);
}
}
}
// Display BW first with half refresh (clean base for grayscale overlay)
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
// Pass 2: LSB buffer - mark DARK gray only (XTH value 1)
// README: "mark the **dark** grays pixels with `1`"
renderer.clearScreen(0x00);
for (uint16_t y = 0; y < pageHeight; y++) {
for (uint16_t x = 0; x < pageWidth; x++) {
if (getPixelValue(x, y) == 1) { // Dark grey only
renderer.drawPixel(x, y, true);
}
}
}
renderer.copyGrayscaleLsbBuffers();
// Pass 3: MSB buffer - mark LIGHT AND DARK gray (XTH value 1 or 2)
// README: "mark the **light and dark** grays pixels with `1`"
renderer.clearScreen(0x00);
for (uint16_t y = 0; y < pageHeight; y++) {
for (uint16_t x = 0; x < pageWidth; x++) {
const uint8_t pv = getPixelValue(x, y);
if (pv == 1 || pv == 2) { // Dark grey or Light grey
renderer.drawPixel(x, y, true);
}
}
}
renderer.copyGrayscaleMsbBuffers();
// Display grayscale overlay
renderer.displayGrayBuffer();
// Pass 4: Re-render BW to framebuffer (restore for next frame, instead of restoreBwBuffer)
renderer.clearScreen();
for (uint16_t y = 0; y < pageHeight; y++) {
for (uint16_t x = 0; x < pageWidth; x++) {
if (getPixelValue(x, y) >= 1) {
renderer.drawPixel(x, y, true);
}
}
}
// Reset refresh counter (grayscale display is a full refresh)
pagesUntilFullRefresh = pagesPerRefresh;
free(pageBuffer);
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (2-bit grayscale)\n", millis(), currentPage + 1,
xtc->getPageCount());
return;
} else {
// 1-bit mode: 8 pixels per byte, MSB first
const size_t srcRowBytes = (pageWidth + 7) / 8; // 60 bytes for 480 width
for (uint16_t srcY = 0; srcY < maxSrcY; srcY++) {
const size_t srcRowStart = srcY * srcRowBytes;
for (uint16_t srcX = 0; srcX < pageWidth; srcX++) {
// Read source pixel (MSB first, bit 7 = leftmost pixel)
const size_t srcByte = srcRowStart + srcX / 8;
const size_t srcBit = 7 - (srcX % 8);
const bool isBlack = !((pageBuffer[srcByte] >> srcBit) & 1); // XTC: 0 = black, 1 = white
if (isBlack) {
renderer.drawPixel(srcX, srcY, true);
}
}
// White pixels are already cleared by clearScreen()
}
}
// White pixels are already cleared by clearScreen()
free(pageBuffer);
@ -216,7 +319,8 @@ void XtcReaderActivity::renderPage() {
pagesUntilFullRefresh--;
}
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu\n", millis(), currentPage + 1, xtc->getPageCount());
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (%u-bit)\n", millis(), currentPage + 1, xtc->getPageCount(),
bitDepth);
}
void XtcReaderActivity::saveProgress() const {