/** * Xtc.cpp * * Main XTC ebook class implementation * XTC ebook support for CrossPoint Reader */ #include "Xtc.h" #include #include bool Xtc::load() { Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str()); // Initialize parser parser.reset(new xtc::XtcParser()); // Open XTC file xtc::XtcError err = parser->open(filepath.c_str()); if (err != xtc::XtcError::OK) { Serial.printf("[%lu] [XTC] Failed to load: %s\n", millis(), xtc::errorToString(err)); parser.reset(); return false; } loaded = true; Serial.printf("[%lu] [XTC] Loaded XTC: %s (%lu pages)\n", millis(), filepath.c_str(), parser->getPageCount()); return true; } bool Xtc::clearCache() const { if (!SdMan.exists(cachePath.c_str())) { Serial.printf("[%lu] [XTC] Cache does not exist, no action needed\n", millis()); return true; } if (!SdMan.removeDir(cachePath.c_str())) { Serial.printf("[%lu] [XTC] Failed to clear cache\n", millis()); return false; } Serial.printf("[%lu] [XTC] Cache cleared successfully\n", millis()); return true; } void Xtc::setupCacheDir() const { if (SdMan.exists(cachePath.c_str())) { return; } // Create directories recursively for (size_t i = 1; i < cachePath.length(); i++) { if (cachePath[i] == '/') { SdMan.mkdir(cachePath.substr(0, i).c_str()); } } SdMan.mkdir(cachePath.c_str()); } std::string Xtc::getTitle() const { if (!loaded || !parser) { return ""; } // Try to get title from XTC metadata first std::string title = parser->getTitle(); if (!title.empty()) { return title; } // Fallback: extract filename from path as title size_t lastSlash = filepath.find_last_of('/'); size_t lastDot = filepath.find_last_of('.'); if (lastSlash == std::string::npos) { lastSlash = 0; } else { lastSlash++; } if (lastDot == std::string::npos || lastDot <= lastSlash) { return filepath.substr(lastSlash); } return filepath.substr(lastSlash, lastDot - lastSlash); } std::string Xtc::getAuthor() const { if (!loaded || !parser) { return ""; } // Try to get author from XTC metadata return parser->getAuthor(); } bool Xtc::hasChapters() const { if (!loaded || !parser) { return false; } return parser->hasChapters(); } const std::vector& Xtc::getChapters() const { static const std::vector kEmpty; if (!loaded || !parser) { return kEmpty; } return parser->getChapters(); } std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } bool Xtc::generateCoverBmp() const { // Already generated if (SdMan.exists(getCoverBmpPath().c_str())) { return true; } if (!loaded || !parser) { Serial.printf("[%lu] [XTC] Cannot generate cover BMP, file not loaded\n", millis()); return false; } if (parser->getPageCount() == 0) { Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis()); return false; } // Setup cache directory setupCacheDir(); // Get first page info for cover xtc::PageInfo pageInfo; if (!parser->getPageInfo(0, pageInfo)) { Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis()); return false; } // Get bit depth const uint8_t bitDepth = parser->getBitDepth(); // Allocate buffer for page data // XTG (1-bit): Row-major, ((width+7)/8) * height bytes // XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes size_t bitmapSize; if (bitDepth == 2) { bitmapSize = ((static_cast(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); return false; } // Load first page (cover) size_t bytesRead = const_cast(parser.get())->loadPage(0, pageBuffer, bitmapSize); if (bytesRead == 0) { Serial.printf("[%lu] [XTC] Failed to load cover page\n", millis()); free(pageBuffer); return false; } // Create BMP file FsFile coverBmp; if (!SdMan.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) { Serial.printf("[%lu] [XTC] Failed to create cover BMP file\n", millis()); free(pageBuffer); return false; } // Write BMP header // BMP file header (14 bytes) const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4; // Row size aligned to 4 bytes const uint32_t imageSize = rowSize * pageInfo.height; const uint32_t fileSize = 14 + 40 + 8 + imageSize; // Header + DIB + palette + data // File header coverBmp.write('B'); coverBmp.write('M'); coverBmp.write(reinterpret_cast(&fileSize), 4); uint32_t reserved = 0; coverBmp.write(reinterpret_cast(&reserved), 4); uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes) coverBmp.write(reinterpret_cast(&dataOffset), 4); // DIB header (BITMAPINFOHEADER - 40 bytes) uint32_t dibHeaderSize = 40; coverBmp.write(reinterpret_cast(&dibHeaderSize), 4); int32_t width = pageInfo.width; coverBmp.write(reinterpret_cast(&width), 4); int32_t height = -static_cast(pageInfo.height); // Negative for top-down coverBmp.write(reinterpret_cast(&height), 4); uint16_t planes = 1; coverBmp.write(reinterpret_cast(&planes), 2); uint16_t bitsPerPixel = 1; // 1-bit monochrome coverBmp.write(reinterpret_cast(&bitsPerPixel), 2); uint32_t compression = 0; // BI_RGB (no compression) coverBmp.write(reinterpret_cast(&compression), 4); coverBmp.write(reinterpret_cast(&imageSize), 4); int32_t ppmX = 2835; // 72 DPI coverBmp.write(reinterpret_cast(&ppmX), 4); int32_t ppmY = 2835; coverBmp.write(reinterpret_cast(&ppmY), 4); uint32_t colorsUsed = 2; coverBmp.write(reinterpret_cast(&colorsUsed), 4); uint32_t colorsImportant = 2; coverBmp.write(reinterpret_cast(&colorsImportant), 4); // Color palette (2 colors for 1-bit) // XTC 1-bit polarity: 0 = black, 1 = white (standard BMP palette order) // Color 0: Black (text/foreground in XTC) uint8_t black[4] = {0x00, 0x00, 0x00, 0x00}; coverBmp.write(black, 4); // Color 1: White (background in XTC) uint8_t white[4] = {0xFF, 0xFF, 0xFF, 0x00}; coverBmp.write(white, 4); // Write bitmap data // BMP requires 4-byte row alignment const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size if (bitDepth == 2) { // XTH 2-bit mode: Two bit planes, column-major order // - Columns scanned right to left (x = width-1 down to 0) // - 8 vertical pixels per byte (MSB = topmost pixel in group) // - First plane: Bit1, Second plane: Bit2 // - Pixel value = (bit1 << 1) | bit2 const size_t planeSize = (static_cast(pageInfo.width) * pageInfo.height + 7) / 8; const uint8_t* plane1 = pageBuffer; // Bit1 plane const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane const size_t colBytes = (pageInfo.height + 7) / 8; // Bytes per column // Allocate a row buffer for 1-bit output uint8_t* rowBuffer = static_cast(malloc(dstRowSize)); if (!rowBuffer) { free(pageBuffer); coverBmp.close(); return false; } for (uint16_t y = 0; y < pageInfo.height; y++) { memset(rowBuffer, 0xFF, dstRowSize); // Start with all white for (uint16_t x = 0; x < pageInfo.width; x++) { // Column-major, right to left: column index = (width - 1 - x) const size_t colIndex = pageInfo.width - 1 - x; const size_t byteInCol = y / 8; const size_t bitInByte = 7 - (y % 8); // MSB = topmost pixel const size_t byteOffset = colIndex * colBytes + byteInCol; const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; const uint8_t pixelValue = (bit1 << 1) | bit2; // Threshold: 0=white (1); 1,2,3=black (0) if (pixelValue >= 1) { // Set bit to 0 (black) in BMP format const size_t dstByte = x / 8; const size_t dstBit = 7 - (x % 8); rowBuffer[dstByte] &= ~(1 << dstBit); } } // Write converted row coverBmp.write(rowBuffer, dstRowSize); // Pad to 4-byte boundary uint8_t padding[4] = {0, 0, 0, 0}; size_t paddingSize = rowSize - dstRowSize; if (paddingSize > 0) { coverBmp.write(padding, paddingSize); } } free(rowBuffer); } else { // 1-bit source: write directly with proper padding const size_t srcRowSize = (pageInfo.width + 7) / 8; for (uint16_t y = 0; y < pageInfo.height; y++) { // Write source row coverBmp.write(pageBuffer + y * srcRowSize, srcRowSize); // Pad to 4-byte boundary uint8_t padding[4] = {0, 0, 0, 0}; size_t paddingSize = rowSize - srcRowSize; if (paddingSize > 0) { coverBmp.write(padding, paddingSize); } } } coverBmp.close(); free(pageBuffer); Serial.printf("[%lu] [XTC] Generated cover BMP: %s\n", millis(), getCoverBmpPath().c_str()); return true; } std::string Xtc::getCoverHomeBmpPath() const { return cachePath + "/cover_home.bmp"; } bool Xtc::generateCoverHomeBmp() const { // Already generated if (SdMan.exists(getCoverHomeBmpPath().c_str())) { return true; } if (!loaded || !parser) { Serial.printf("[%lu] [XTC] Cannot generate thumb BMP, file not loaded\n", millis()); return false; } if (parser->getPageCount() == 0) { Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis()); return false; } // Setup cache directory setupCacheDir(); // Get first page info for cover xtc::PageInfo pageInfo; if (!parser->getPageInfo(0, pageInfo)) { Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis()); return false; } // Get bit depth const uint8_t bitDepth = parser->getBitDepth(); // For home screen, use 400px height with proportional width for optimal performance // Generate 1-bit BMP for fast home screen rendering (no gray passes needed) constexpr int HOME_TARGET_HEIGHT = 400; // Calculate proportional width for 400px height const uint16_t targetWidth = static_cast((HOME_TARGET_HEIGHT * pageInfo.width) / pageInfo.height); Serial.printf("[%lu] [XTC] Generating home BMP: %dx%d -> %dx%d\n", millis(), pageInfo.width, pageInfo.height, targetWidth, HOME_TARGET_HEIGHT); Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width, pageInfo.height, targetWidth, HOME_TARGET_HEIGHT); // Allocate buffer for page data 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); return false; } // Load first page (cover) size_t bytesRead = const_cast(parser.get())->loadPage(0, pageBuffer, bitmapSize); if (bytesRead == 0) { Serial.printf("[%lu] [XTC] Failed to load cover page for thumb\n", millis()); free(pageBuffer); return false; } // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) FsFile thumbBmp; if (!SdMan.openFileForWrite("XTC", getCoverHomeBmpPath(), thumbBmp)) { Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); free(pageBuffer); return false; } // Write 1-bit BMP header for fast home screen rendering const uint32_t rowSize = (targetWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes const uint32_t imageSize = rowSize * HOME_TARGET_HEIGHT; const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette // File header thumbBmp.write('B'); thumbBmp.write('M'); thumbBmp.write(reinterpret_cast(&fileSize), 4); uint32_t reserved = 0; thumbBmp.write(reinterpret_cast(&reserved), 4); uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes) thumbBmp.write(reinterpret_cast(&dataOffset), 4); // DIB header uint32_t dibHeaderSize = 40; thumbBmp.write(reinterpret_cast(&dibHeaderSize), 4); int32_t widthVal = targetWidth; thumbBmp.write(reinterpret_cast(&widthVal), 4); int32_t heightVal = -static_cast(HOME_TARGET_HEIGHT); // Negative for top-down thumbBmp.write(reinterpret_cast(&heightVal), 4); uint16_t planes = 1; thumbBmp.write(reinterpret_cast(&planes), 2); uint16_t bitsPerPixel = 1; // 1-bit for black and white thumbBmp.write(reinterpret_cast(&bitsPerPixel), 2); uint32_t compression = 0; thumbBmp.write(reinterpret_cast(&compression), 4); thumbBmp.write(reinterpret_cast(&imageSize), 4); int32_t ppmX = 2835; thumbBmp.write(reinterpret_cast(&ppmX), 4); int32_t ppmY = 2835; thumbBmp.write(reinterpret_cast(&ppmY), 4); uint32_t colorsUsed = 2; thumbBmp.write(reinterpret_cast(&colorsUsed), 4); uint32_t colorsImportant = 2; thumbBmp.write(reinterpret_cast(&colorsImportant), 4); // Color palette (2 colors for 1-bit: black and white) uint8_t palette[8] = { 0x00, 0x00, 0x00, 0x00, // Color 0: Black 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White }; thumbBmp.write(palette, 8); // Allocate row buffer for 1-bit output uint8_t* rowBuffer = static_cast(malloc(rowSize)); if (!rowBuffer) { free(pageBuffer); thumbBmp.close(); return false; } // Fixed-point scale factor (16.16) - scale to fit 400px height uint32_t scaleInv_fp = static_cast(65536.0f * static_cast(pageInfo.height) / HOME_TARGET_HEIGHT); // Pre-calculate plane info for 2-bit mode const size_t planeSize = (bitDepth == 2) ? ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) : 0; const uint8_t* plane1 = (bitDepth == 2) ? pageBuffer : nullptr; const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr; const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0; const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0; for (uint16_t dstY = 0; dstY < HOME_TARGET_HEIGHT; dstY++) { memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1) // Calculate source Y range with bounds checking uint32_t srcYStart = (static_cast(dstY) * scaleInv_fp) >> 16; uint32_t srcYEnd = (static_cast(dstY + 1) * scaleInv_fp) >> 16; if (srcYStart >= pageInfo.height) srcYStart = pageInfo.height - 1; if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1; if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; for (uint16_t dstX = 0; dstX < targetWidth; dstX++) { // Calculate source X range with bounds checking uint32_t srcXStart = (static_cast(dstX) * scaleInv_fp) >> 16; uint32_t srcXEnd = (static_cast(dstX + 1) * scaleInv_fp) >> 16; if (srcXStart >= pageInfo.width) srcXStart = pageInfo.width - 1; if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; if (srcXEnd <= srcXStart) srcXEnd = srcXStart + 1; if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; // Area averaging: sum grayscale values (0-255 range) uint32_t graySum = 0; uint32_t totalCount = 0; for (uint32_t srcY = srcYStart; srcY < srcYEnd && srcY < pageInfo.height; srcY++) { for (uint32_t srcX = srcXStart; srcX < srcXEnd && srcX < pageInfo.width; srcX++) { uint8_t grayValue = 255; // Default: white if (bitDepth == 2) { // XTH 2-bit mode: pixel value 0-3 // Bounds check for column index if (srcX < pageInfo.width) { const size_t colIndex = pageInfo.width - 1 - srcX; const size_t byteInCol = srcY / 8; const size_t bitInByte = 7 - (srcY % 8); const size_t byteOffset = colIndex * colBytes + byteInCol; // Bounds check for buffer access if (byteOffset < planeSize) { const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; const uint8_t pixelValue = (bit1 << 1) | bit2; // Convert 2-bit (0-3) to grayscale: 0=black, 3=white // pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity) grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0 } } } else { // 1-bit mode const size_t byteIdx = srcY * srcRowBytes + srcX / 8; const size_t bitIdx = 7 - (srcX % 8); // Bounds check for buffer access if (byteIdx < bitmapSize) { const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1; // XTC 1-bit polarity: 0=black, 1=white (same as BMP palette) grayValue = pixelBit ? 255 : 0; } } graySum += grayValue; totalCount++; } } // Calculate average grayscale and quantize to 1-bit with noise dithering uint8_t avgGray = (totalCount > 0) ? static_cast(graySum / totalCount) : 255; // Hash-based noise dithering for 1-bit output uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; hash = (hash ^ (hash >> 13)) * 1274126177u; const int threshold = static_cast(hash >> 24); // 0-255 const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 // Quantize to 1-bit: 0=black, 1=white uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0; // Pack 1-bit value into row buffer (MSB first, 8 pixels per byte) const size_t byteIndex = dstX / 8; const size_t bitOffset = 7 - (dstX % 8); // Bounds check for row buffer access if (byteIndex < rowSize) { if (oneBit) { rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white } else { rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black } } } // Write row (already padded to 4-byte boundary by rowSize) thumbBmp.write(rowBuffer, rowSize); } free(rowBuffer); thumbBmp.close(); free(pageBuffer); Serial.printf("[%lu] [XTC] Generated home BMP (%dx%d): %s\n", millis(), targetWidth, HOME_TARGET_HEIGHT, getCoverHomeBmpPath().c_str()); return true; } uint32_t Xtc::getPageCount() const { if (!loaded || !parser) { return 0; } return parser->getPageCount(); } uint16_t Xtc::getPageWidth() const { if (!loaded || !parser) { return 0; } return parser->getWidth(); } uint16_t Xtc::getPageHeight() const { if (!loaded || !parser) { return 0; } return parser->getHeight(); } uint8_t Xtc::getBitDepth() const { if (!loaded || !parser) { return 1; // Default to 1-bit } return parser->getBitDepth(); } size_t Xtc::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const { if (!loaded || !parser) { return 0; } return const_cast(parser.get())->loadPage(pageIndex, buffer, bufferSize); } xtc::XtcError Xtc::loadPageStreaming(uint32_t pageIndex, std::function callback, size_t chunkSize) const { if (!loaded || !parser) { return xtc::XtcError::FILE_NOT_FOUND; } return const_cast(parser.get())->loadPageStreaming(pageIndex, callback, chunkSize); } uint8_t Xtc::calculateProgress(uint32_t currentPage) const { if (!loaded || !parser || parser->getPageCount() == 0) { return 0; } return static_cast((currentPage + 1) * 100 / parser->getPageCount()); } xtc::XtcError Xtc::getLastError() const { if (!parser) { return xtc::XtcError::FILE_NOT_FOUND; } return parser->getLastError(); }