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:
parent
bcda77aed5
commit
4ef35afb4d
40
lib/Xtc/README
Normal file
40
lib/Xtc/README
Normal 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>
|
||||||
101
lib/Xtc/Xtc.cpp
101
lib/Xtc/Xtc.cpp
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user