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
This commit is contained in:
Eunchurn Park 2025-12-28 12:39:10 +09:00
parent bcda77aed5
commit 4ef35afb4d
No known key found for this signature in database
GPG Key ID: 29D94D9C697E3F92
10 changed files with 295 additions and 78 deletions

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; return false;
} }
// Allocate buffer for page data (XTC is always 1-bit monochrome) // Get bit depth
const size_t bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; 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)); uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
if (!pageBuffer) { if (!pageBuffer) {
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize); 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); coverBmp.write(white, 4);
// Write bitmap data // Write bitmap data
// XTC stores data as packed bits, same as BMP 1-bit format // BMP requires 4-byte row alignment
// But we need to ensure proper row alignment (4-byte boundary) const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size
const size_t srcRowSize = (pageInfo.width + 7) / 8; // Source row size
for (uint16_t y = 0; y < pageInfo.height; y++) { if (bitDepth == 2) {
// Write source row // XTH 2-bit mode: Two bit planes, column-major order
coverBmp.write(pageBuffer + y * srcRowSize, srcRowSize); // - 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 // Allocate a row buffer for 1-bit output
uint8_t padding[4] = {0, 0, 0, 0}; uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(dstRowSize));
size_t paddingSize = rowSize - srcRowSize; if (!rowBuffer) {
if (paddingSize > 0) { free(pageBuffer);
coverBmp.write(padding, paddingSize); 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(); 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 { size_t Xtc::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const {
if (!loaded || !parser) { if (!loaded || !parser) {
return 0; return 0;

View File

@ -64,6 +64,7 @@ class Xtc {
uint32_t getPageCount() const; uint32_t getPageCount() const;
uint16_t getPageWidth() const; uint16_t getPageWidth() const;
uint16_t getPageHeight() const; uint16_t getPageHeight() const;
uint8_t getBitDepth() const; // 1 = XTC (1-bit), 2 = XTCH (2-bit)
/** /**
* Load page bitmap data * Load page bitmap data

View File

@ -14,7 +14,11 @@
namespace xtc { namespace xtc {
XtcParser::XtcParser() 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)); memset(&m_header, 0, sizeof(m_header));
} }
@ -76,12 +80,16 @@ XtcError XtcParser::readHeader() {
return XtcError::READ_ERROR; return XtcError::READ_ERROR;
} }
// Verify magic number // Verify magic number (accept both XTC and XTCH)
if (m_header.magic != XTC_MAGIC) { if (m_header.magic != XTC_MAGIC && m_header.magic != XTCH_MAGIC) {
Serial.printf("[%lu] [XTC] Invalid magic: 0x%08X (expected 0x%08X)\n", millis(), m_header.magic, XTC_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; return XtcError::INVALID_MAGIC;
} }
// Determine bit depth from file magic
m_bitDepth = (m_header.magic == XTCH_MAGIC) ? 2 : 1;
// Check version // Check version
if (m_header.version > 1) { if (m_header.version > 1) {
Serial.printf("[%lu] [XTC] Unsupported version: %d\n", millis(), m_header.version); Serial.printf("[%lu] [XTC] Unsupported version: %d\n", millis(), m_header.version);
@ -93,8 +101,8 @@ XtcError XtcParser::readHeader() {
return XtcError::CORRUPTED_HEADER; return XtcError::CORRUPTED_HEADER;
} }
Serial.printf("[%lu] [XTC] Header: magic=0x%08X, ver=%u, pages=%u, pageTableOff=%llu, dataOff=%llu\n", millis(), Serial.printf("[%lu] [XTC] Header: magic=0x%08X (%s), ver=%u, pages=%u, bitDepth=%u\n", millis(), m_header.magic,
m_header.magic, m_header.version, m_header.pageCount, m_header.pageTableOffset, m_header.dataOffset); (m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.version, m_header.pageCount, m_bitDepth);
return XtcError::OK; return XtcError::OK;
} }
@ -145,6 +153,7 @@ XtcError XtcParser::readPageTable() {
m_pageTable[i].size = entry.dataSize; m_pageTable[i].size = entry.dataSize;
m_pageTable[i].width = entry.width; m_pageTable[i].width = entry.width;
m_pageTable[i].height = entry.height; m_pageTable[i].height = entry.height;
m_pageTable[i].bitDepth = m_bitDepth;
// Update default dimensions from first page // Update default dimensions from first page
if (i == 0) { if (i == 0) {
@ -185,24 +194,34 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
return 0; return 0;
} }
// Read XTG header first // Read page header (XTG for 1-bit, XTH for 2-bit - same structure)
XtgPageHeader xtgHeader; XtgPageHeader pageHeader;
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&xtgHeader), sizeof(XtgPageHeader)); size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
if (headerRead != 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; m_lastError = XtcError::READ_ERROR;
return 0; return 0;
} }
// Verify XTG magic // Verify page magic (XTG for 1-bit, XTH for 2-bit)
if (xtgHeader.magic != XTG_MAGIC) { const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
Serial.printf("[%lu] [XTC] Invalid XTG magic for page %u: 0x%08X\n", millis(), pageIndex, xtgHeader.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; m_lastError = XtcError::INVALID_MAGIC;
return 0; return 0;
} }
// Calculate bitmap size // Calculate bitmap size based on bit depth
const size_t bitmapSize = ((xtgHeader.width + 7) / 8) * xtgHeader.height; // 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 // Check buffer size
if (bufferSize < bitmapSize) { if (bufferSize < bitmapSize) {
@ -241,15 +260,23 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
return XtcError::READ_ERROR; return XtcError::READ_ERROR;
} }
// Read and skip XTG header // Read and skip page header (XTG for 1-bit, XTH for 2-bit)
XtgPageHeader xtgHeader; XtgPageHeader pageHeader;
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&xtgHeader), sizeof(XtgPageHeader)); size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
if (headerRead != sizeof(XtgPageHeader) || xtgHeader.magic != XTG_MAGIC) { const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
if (headerRead != sizeof(XtgPageHeader) || pageHeader.magic != expectedMagic) {
return XtcError::READ_ERROR; return XtcError::READ_ERROR;
} }
// Calculate bitmap size // Calculate bitmap size based on bit depth
const size_t bitmapSize = ((xtgHeader.width + 7) / 8) * xtgHeader.height; // 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 // Read in chunks
std::vector<uint8_t> chunk(chunkSize); std::vector<uint8_t> chunk(chunkSize);
@ -284,7 +311,7 @@ bool XtcParser::isValidXtcFile(const char* filepath) {
return false; return false;
} }
return (magic == XTC_MAGIC); return (magic == XTC_MAGIC || magic == XTCH_MAGIC);
} }
} // namespace xtc } // namespace xtc

View File

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

View File

@ -18,9 +18,13 @@ namespace xtc {
// XTC file magic numbers (little-endian) // XTC file magic numbers (little-endian)
// "XTC\0" = 0x58, 0x54, 0x43, 0x00 // "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 // "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 // XTeink X4 display resolution
constexpr uint16_t DISPLAY_WIDTH = 480; constexpr uint16_t DISPLAY_WIDTH = 480;
@ -54,26 +58,37 @@ struct PageTableEntry {
}; };
#pragma pack(pop) #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) #pragma pack(push, 1)
struct XtgPageHeader { struct XtgPageHeader {
uint32_t magic; // 0x00: Magic "XTG\0" (0x00475458) uint32_t magic; // 0x00: File identifier (XTG: 0x00475458, XTH: 0x00485458)
uint16_t width; // 0x04: Bitmap width uint16_t width; // 0x04: Image width (pixels)
uint16_t height; // 0x06: Bitmap height uint16_t height; // 0x06: Image height (pixels)
uint16_t reserved1; // 0x08: Reserved (0) uint8_t colorMode; // 0x08: Color mode (0=monochrome)
uint32_t bitmapSize; // 0x0A: Bitmap data size = ((width+7)/8) * height uint8_t compression; // 0x09: Compression (0=uncompressed)
uint32_t reserved2; // 0x0E: Reserved (0) uint32_t dataSize; // 0x0A: Image data size (bytes)
uint32_t reserved3; // 0x12: Reserved (0) uint64_t md5; // 0x0E: MD5 checksum (first 8 bytes, optional)
// Followed by bitmap data at offset 0x16 (22) // 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) #pragma pack(pop)
// Page information (internal use) // Page information (internal use)
struct PageInfo { struct PageInfo {
uint64_t offset; // File offset to page data uint64_t offset; // File offset to page data
uint32_t size; // Data size (bytes) uint32_t size; // Data size (bytes)
uint16_t width; // Page width uint16_t width; // Page width
uint16_t height; // Page height uint16_t height; // Page height
uint8_t bitDepth; // 1 = XTG (1-bit), 2 = XTH (2-bit grayscale)
}; };
// Error codes // Error codes
@ -119,13 +134,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) { inline bool isXtcExtension(const char* filename) {
if (!filename) return false; if (!filename) return false;
const char* ext = strrchr(filename, '.'); const char* ext = strrchr(filename, '.');
if (!ext) return false; 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 } // namespace xtc

View File

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

View File

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

View File

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

View File

@ -150,9 +150,17 @@ void XtcReaderActivity::renderScreen() {
void XtcReaderActivity::renderPage() { void XtcReaderActivity::renderPage() {
const uint16_t pageWidth = xtc->getPageWidth(); const uint16_t pageWidth = xtc->getPageWidth();
const uint16_t pageHeight = xtc->getPageHeight(); const uint16_t pageHeight = xtc->getPageHeight();
const uint8_t bitDepth = xtc->getBitDepth();
// Calculate buffer size for one page (XTC is always 1-bit monochrome) // Calculate buffer size for one page
const size_t pageBufferSize = ((pageWidth + 7) / 8) * pageHeight; // 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 // Allocate page buffer
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize)); uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize));
@ -179,29 +187,62 @@ void XtcReaderActivity::renderPage() {
renderer.clearScreen(); renderer.clearScreen();
// Copy page bitmap using GfxRenderer's drawPixel // Copy page bitmap using GfxRenderer's drawPixel
// XTC stores 1-bit packed data in portrait (480x800) format // XTC/XTCH pages are pre-rendered with status bar included, so render full page
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
const uint16_t maxSrcY = pageHeight; const uint16_t maxSrcY = pageHeight;
for (uint16_t srcY = 0; srcY < maxSrcY; srcY++) { if (bitDepth == 2) {
const size_t srcRowStart = srcY * srcRowBytes; // 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++) { const size_t planeSize = (static_cast<size_t>(pageWidth) * pageHeight + 7) / 8;
// Read source pixel (MSB first, bit 7 = leftmost pixel) const uint8_t* plane1 = pageBuffer; // Bit1 plane
const size_t srcByte = srcRowStart + srcX / 8; const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
const size_t srcBit = 7 - (srcX % 8); const size_t colBytes = (pageHeight + 7) / 8; // Bytes per column (100 for 800 height)
const bool isBlack = !((pageBuffer[srcByte] >> srcBit) & 1); // XTC: 0 = black, 1 = white
// Use GfxRenderer's drawPixel with logical portrait coordinates for (uint16_t y = 0; y < pageHeight; y++) {
// drawPixel(x, y, state) where state=true draws black for (uint16_t x = 0; x < pageWidth; x++) {
if (isBlack) { // Column-major, right to left: column index = (width - 1 - x)
renderer.drawPixel(srcX, srcY, true); const size_t colIndex = pageWidth - 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,2,3=Dark (for best text contrast)
const bool isBlack = (pixelValue >= 1);
if (isBlack) {
renderer.drawPixel(x, y, true);
}
}
}
} 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); free(pageBuffer);
@ -216,7 +257,8 @@ void XtcReaderActivity::renderPage() {
pagesUntilFullRefresh--; 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 { void XtcReaderActivity::saveProgress() const {