mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-06 23:57:39 +03:00
## Summary ### What is the goal of this PR? - Visual UI overhaul - UI theme selection ### What changes are included? - Added a setting "UI Theme": Classic, Lyra - The classic theme is the current Crosspoint theme - The Lyra theme implements these mockups: https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2003-7596&t=4CSOZqf0n9uQMxDt-0 by Discord users yagofarias, ruby and gan_shu - New functions in GFXRenderer to render rounded rectangles, greyscale fills (using dithering) and thick lines - Basic UI components are factored into BaseTheme methods which can be overridden by each additional theme. Methods that are not overridden will fallback to BaseTheme behavior. This means any new features/components in CrossPoint only need to be developed for the "Classic" BaseTheme. - Additional themes can easily be developed by the community using this foundation    ## Additional Context - Only the Home, Library and main Settings screens have been implemented so far, this will be extended to the transfer screens and chapter selection screen later on, but we need to get the ball rolling somehow :) - Loading extra covers on the home screen in the Lyra theme takes a little more time (about 2 seconds), I added a loading bar popup (reusing the Indexing progress bar from the reader view, factored into a neat UI component) but the popup adds ~400ms to the loading time. - ~~Home screen thumbnails will need to be generated separately for each theme, because they are displayed in different sizes. Because we're using dithering, displaying a thumb with the wrong size causes the picture to look janky or dark as it does on the screenshots above. No worries this will be fixed in a future PR.~~ Thumbs are now generated with a size parameter - UI Icons will need to be implemented in a future PR. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**PARTIALLY**_ This is not a vibe coded PR. Copilot was used for autocompletion to save time but I reviewed, understood and edited all generated code. --------- Co-authored-by: Dave Allie <dave@daveallie.com>
623 lines
21 KiB
C++
623 lines
21 KiB
C++
/**
|
|
* Xtc.cpp
|
|
*
|
|
* Main XTC ebook class implementation
|
|
* XTC ebook support for CrossPoint Reader
|
|
*/
|
|
|
|
#include "Xtc.h"
|
|
|
|
#include <HardwareSerial.h>
|
|
#include <SDCardManager.h>
|
|
|
|
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::ChapterInfo>& Xtc::getChapters() const {
|
|
static const std::vector<xtc::ChapterInfo> 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<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) * 2;
|
|
} else {
|
|
bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height;
|
|
}
|
|
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
|
|
if (!pageBuffer) {
|
|
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize);
|
|
return false;
|
|
}
|
|
|
|
// Load first page (cover)
|
|
size_t bytesRead = const_cast<xtc::XtcParser*>(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<const uint8_t*>(&fileSize), 4);
|
|
uint32_t reserved = 0;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
|
|
uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes)
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
|
|
|
|
// DIB header (BITMAPINFOHEADER - 40 bytes)
|
|
uint32_t dibHeaderSize = 40;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
|
|
int32_t width = pageInfo.width;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&width), 4);
|
|
int32_t height = -static_cast<int32_t>(pageInfo.height); // Negative for top-down
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&height), 4);
|
|
uint16_t planes = 1;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
|
|
uint16_t bitsPerPixel = 1; // 1-bit monochrome
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
|
|
uint32_t compression = 0; // BI_RGB (no compression)
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
|
|
int32_t ppmX = 2835; // 72 DPI
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
|
|
int32_t ppmY = 2835;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
|
|
uint32_t colorsUsed = 2;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
|
|
uint32_t colorsImportant = 2;
|
|
coverBmp.write(reinterpret_cast<const uint8_t*>(&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<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
|
|
|
|
// Allocate a row buffer for 1-bit output
|
|
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(dstRowSize));
|
|
if (!rowBuffer) {
|
|
free(pageBuffer);
|
|
coverBmp.close();
|
|
return false;
|
|
}
|
|
|
|
for (uint16_t y = 0; y < pageInfo.height; y++) {
|
|
memset(rowBuffer, 0xFF, dstRowSize); // Start with all white
|
|
|
|
for (uint16_t x = 0; x < pageInfo.width; x++) {
|
|
// Column-major, right to left: column index = (width - 1 - x)
|
|
const size_t colIndex = pageInfo.width - 1 - x;
|
|
const size_t byteInCol = y / 8;
|
|
const size_t bitInByte = 7 - (y % 8); // MSB = topmost pixel
|
|
|
|
const size_t byteOffset = colIndex * colBytes + byteInCol;
|
|
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
|
|
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
|
|
const uint8_t pixelValue = (bit1 << 1) | bit2;
|
|
|
|
// Threshold: 0=white (1); 1,2,3=black (0)
|
|
if (pixelValue >= 1) {
|
|
// Set bit to 0 (black) in BMP format
|
|
const size_t dstByte = x / 8;
|
|
const size_t dstBit = 7 - (x % 8);
|
|
rowBuffer[dstByte] &= ~(1 << dstBit);
|
|
}
|
|
}
|
|
|
|
// Write converted row
|
|
coverBmp.write(rowBuffer, dstRowSize);
|
|
|
|
// Pad to 4-byte boundary
|
|
uint8_t padding[4] = {0, 0, 0, 0};
|
|
size_t paddingSize = rowSize - dstRowSize;
|
|
if (paddingSize > 0) {
|
|
coverBmp.write(padding, paddingSize);
|
|
}
|
|
}
|
|
|
|
free(rowBuffer);
|
|
} else {
|
|
// 1-bit source: write directly with proper padding
|
|
const size_t srcRowSize = (pageInfo.width + 7) / 8;
|
|
|
|
for (uint16_t y = 0; y < pageInfo.height; y++) {
|
|
// Write source row
|
|
coverBmp.write(pageBuffer + y * srcRowSize, srcRowSize);
|
|
|
|
// Pad to 4-byte boundary
|
|
uint8_t padding[4] = {0, 0, 0, 0};
|
|
size_t paddingSize = rowSize - srcRowSize;
|
|
if (paddingSize > 0) {
|
|
coverBmp.write(padding, paddingSize);
|
|
}
|
|
}
|
|
}
|
|
|
|
coverBmp.close();
|
|
free(pageBuffer);
|
|
|
|
Serial.printf("[%lu] [XTC] Generated cover BMP: %s\n", millis(), getCoverBmpPath().c_str());
|
|
return true;
|
|
}
|
|
|
|
std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
|
|
std::string Xtc::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
|
|
|
|
bool Xtc::generateThumbBmp(int height) const {
|
|
// Already generated
|
|
if (SdMan.exists(getThumbBmpPath(height).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();
|
|
|
|
// Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card)
|
|
int THUMB_TARGET_WIDTH = height * 0.6;
|
|
int THUMB_TARGET_HEIGHT = height;
|
|
|
|
// Calculate scale factor
|
|
float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width;
|
|
float scaleY = static_cast<float>(THUMB_TARGET_HEIGHT) / pageInfo.height;
|
|
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
|
|
|
// Only scale down, never up
|
|
if (scale >= 1.0f) {
|
|
// Page is already small enough, just use cover.bmp
|
|
// Copy cover.bmp to thumb.bmp
|
|
if (generateCoverBmp()) {
|
|
FsFile src, dst;
|
|
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) {
|
|
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(height), dst)) {
|
|
uint8_t buffer[512];
|
|
while (src.available()) {
|
|
size_t bytesRead = src.read(buffer, sizeof(buffer));
|
|
dst.write(buffer, bytesRead);
|
|
}
|
|
dst.close();
|
|
}
|
|
src.close();
|
|
}
|
|
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis());
|
|
return SdMan.exists(getThumbBmpPath(height).c_str());
|
|
}
|
|
return false;
|
|
}
|
|
|
|
uint16_t thumbWidth = static_cast<uint16_t>(pageInfo.width * scale);
|
|
uint16_t thumbHeight = static_cast<uint16_t>(pageInfo.height * scale);
|
|
|
|
Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width,
|
|
pageInfo.height, thumbWidth, thumbHeight, scale);
|
|
|
|
// Allocate buffer for page data
|
|
size_t bitmapSize;
|
|
if (bitDepth == 2) {
|
|
bitmapSize = ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) * 2;
|
|
} else {
|
|
bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height;
|
|
}
|
|
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
|
|
if (!pageBuffer) {
|
|
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize);
|
|
return false;
|
|
}
|
|
|
|
// Load first page (cover)
|
|
size_t bytesRead = const_cast<xtc::XtcParser*>(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", getThumbBmpPath(height), 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 = (thumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes
|
|
const uint32_t imageSize = rowSize * thumbHeight;
|
|
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<const uint8_t*>(&fileSize), 4);
|
|
uint32_t reserved = 0;
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
|
|
uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes)
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
|
|
|
|
// DIB header
|
|
uint32_t dibHeaderSize = 40;
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
|
|
int32_t widthVal = thumbWidth;
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&widthVal), 4);
|
|
int32_t heightVal = -static_cast<int32_t>(thumbHeight); // Negative for top-down
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&heightVal), 4);
|
|
uint16_t planes = 1;
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
|
|
uint16_t bitsPerPixel = 1; // 1-bit for black and white
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
|
|
uint32_t compression = 0;
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&imageSize), 4);
|
|
int32_t ppmX = 2835;
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
|
|
int32_t ppmY = 2835;
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
|
|
uint32_t colorsUsed = 2;
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
|
|
uint32_t colorsImportant = 2;
|
|
thumbBmp.write(reinterpret_cast<const uint8_t*>(&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<uint8_t*>(malloc(rowSize));
|
|
if (!rowBuffer) {
|
|
free(pageBuffer);
|
|
thumbBmp.close();
|
|
return false;
|
|
}
|
|
|
|
// Fixed-point scale factor (16.16)
|
|
uint32_t scaleInv_fp = static_cast<uint32_t>(65536.0f / scale);
|
|
|
|
// Pre-calculate plane info for 2-bit mode
|
|
const size_t planeSize = (bitDepth == 2) ? ((static_cast<size_t>(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 < thumbHeight; dstY++) {
|
|
memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1)
|
|
|
|
// Calculate source Y range with bounds checking
|
|
uint32_t srcYStart = (static_cast<uint32_t>(dstY) * scaleInv_fp) >> 16;
|
|
uint32_t srcYEnd = (static_cast<uint32_t>(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 < thumbWidth; dstX++) {
|
|
// Calculate source X range with bounds checking
|
|
uint32_t srcXStart = (static_cast<uint32_t>(dstX) * scaleInv_fp) >> 16;
|
|
uint32_t srcXEnd = (static_cast<uint32_t>(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<uint8_t>(graySum / totalCount) : 255;
|
|
|
|
// Hash-based noise dithering for 1-bit output
|
|
uint32_t hash = static_cast<uint32_t>(dstX) * 374761393u + static_cast<uint32_t>(dstY) * 668265263u;
|
|
hash = (hash ^ (hash >> 13)) * 1274126177u;
|
|
const int threshold = static_cast<int>(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 thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight,
|
|
getThumbBmpPath(height).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<xtc::XtcParser*>(parser.get())->loadPage(pageIndex, buffer, bufferSize);
|
|
}
|
|
|
|
xtc::XtcError Xtc::loadPageStreaming(uint32_t pageIndex,
|
|
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
|
size_t chunkSize) const {
|
|
if (!loaded || !parser) {
|
|
return xtc::XtcError::FILE_NOT_FOUND;
|
|
}
|
|
return const_cast<xtc::XtcParser*>(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<uint8_t>((currentPage + 1) * 100 / parser->getPageCount());
|
|
}
|
|
|
|
xtc::XtcError Xtc::getLastError() const {
|
|
if (!parser) {
|
|
return xtc::XtcError::FILE_NOT_FOUND;
|
|
}
|
|
return parser->getLastError();
|
|
}
|