From 4ef35afb4d5de6d9b65067eb48d5651a2521385d Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 28 Dec 2025 12:39:10 +0900 Subject: [PATCH] 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 --- lib/Xtc/README | 40 +++++++ lib/Xtc/Xtc.cpp | 101 +++++++++++++++--- lib/Xtc/Xtc.h | 1 + lib/Xtc/Xtc/XtcParser.cpp | 71 ++++++++---- lib/Xtc/Xtc/XtcParser.h | 2 + lib/Xtc/Xtc/XtcTypes.h | 47 +++++--- src/activities/boot_sleep/SleepActivity.cpp | 11 +- .../reader/FileSelectionActivity.cpp | 11 +- src/activities/reader/ReaderActivity.cpp | 9 +- src/activities/reader/XtcReaderActivity.cpp | 80 ++++++++++---- 10 files changed, 295 insertions(+), 78 deletions(-) create mode 100644 lib/Xtc/README diff --git a/lib/Xtc/README b/lib/Xtc/README new file mode 100644 index 0000000..1f55eff --- /dev/null +++ b/lib/Xtc/README @@ -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: diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 4fe7b99..fe0b107 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -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(pageInfo.width) * pageInfo.height + 7) / 8) * 2; + } else { + bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; + } uint8_t* pageBuffer = static_cast(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(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(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; diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index c57b01a..42e05ef 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -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 diff --git a/lib/Xtc/Xtc/XtcParser.cpp b/lib/Xtc/Xtc/XtcParser.cpp index 49a5071..41b5796 100644 --- a/lib/Xtc/Xtc/XtcParser.cpp +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -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; } @@ -145,6 +153,7 @@ XtcError XtcParser::readPageTable() { 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) { @@ -185,24 +194,34 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz return 0; } - // Read XTG header first - XtgPageHeader xtgHeader; - size_t headerRead = m_file.read(reinterpret_cast(&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(&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(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(&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(&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(pageHeader.width) * pageHeader.height + 7) / 8) * 2; + } else { + bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height; + } // Read in chunks std::vector 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 diff --git a/lib/Xtc/Xtc/XtcParser.h b/lib/Xtc/Xtc/XtcParser.h index 23d01b0..b0a402a 100644 --- a/lib/Xtc/Xtc/XtcParser.h +++ b/lib/Xtc/Xtc/XtcParser.h @@ -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 diff --git a/lib/Xtc/Xtc/XtcTypes.h b/lib/Xtc/Xtc/XtcTypes.h index 9e501cf..97b1f69 100644 --- a/lib/Xtc/Xtc/XtcTypes.h +++ b/lib/Xtc/Xtc/XtcTypes.h @@ -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,26 +58,37 @@ 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) 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 + uint64_t offset; // File offset to page data + 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) }; // 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) { 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 diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 040a06f..ea09eb5 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -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 diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index e37b8e8..a288e61 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -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(); diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 7f9ac7a..222cc97 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -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 ReaderActivity::loadEpub(const std::string& path) { diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index d329752..37ef54d 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -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(pageWidth) * pageHeight + 7) / 8) * 2; + } else { + pageBufferSize = ((pageWidth + 7) / 8) * pageHeight; + } // Allocate page buffer uint8_t* pageBuffer = static_cast(malloc(pageBufferSize)); @@ -179,29 +187,62 @@ 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(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); + for (uint16_t y = 0; y < pageHeight; y++) { + for (uint16_t x = 0; x < pageWidth; x++) { + // Column-major, right to left: column index = (width - 1 - x) + 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); @@ -216,7 +257,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 {