Compare commits

..

No commits in common. "8fc51668d0912ea73e43b60a5df444cda718b7ec" and "6fbdd061019a418ea662fd79cd82b5302f3e07f6" have entirely different histories.

9 changed files with 106 additions and 436 deletions

View File

@ -369,11 +369,10 @@ bool Epub::generateThumbBmp() const {
return false;
}
// Use smaller target size for Continue Reading card (half of screen: 240x400)
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
constexpr int THUMB_TARGET_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
THUMB_TARGET_HEIGHT);
const bool success =
JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
coverJpg.close();
thumbBmp.close();
SdMan.remove(coverJpgTempPath.c_str());

View File

@ -341,10 +341,7 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) cons
}
case 1: {
for (int x = 0; x < width; x++) {
// Get palette index (0 or 1) from bit at position x
const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0;
// Use palette lookup for proper black/white mapping
lum = paletteLum[palIndex];
lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00;
packPixel(lum);
}
break;

View File

@ -38,8 +38,6 @@ class Bitmap {
bool isTopDown() const { return topDown; }
bool hasGreyscale() const { return bpp > 1; }
int getRowBytes() const { return rowBytes; }
bool is1Bit() const { return bpp == 1; }
uint16_t getBpp() const { return bpp; }
private:
static uint16_t readLE16(FsFile& f);

View File

@ -154,12 +154,6 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
const int maxHeight) const {
// For 1-bit bitmaps, use optimized 1-bit rendering path
if (bitmap.is1Bit()) {
drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight);
return;
}
float scale = 1.0f;
bool isScaled = false;
if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
@ -228,141 +222,6 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
free(rowBytes);
}
void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
const int maxHeight) const {
float scale = 1.0f;
bool isScaled = false;
if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
scale = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
isScaled = true;
}
if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight()));
isScaled = true;
}
// For 1-bit BMP, output is still 2-bit packed (for consistency with readRow)
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) {
Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", millis());
free(outputRow);
free(rowBytes);
return;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
int screenY = y + (isScaled ? static_cast<int>(std::floor(bmpYOffset * scale)) : bmpYOffset);
if (screenY >= getScreenHeight()) {
break;
}
if (screenY < 0) {
continue;
}
if (bitmap.readRow(outputRow, rowBytes, bmpY) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", millis(), bmpY);
free(outputRow);
free(rowBytes);
return;
}
for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) {
int screenX = x + (isScaled ? static_cast<int>(std::floor(bmpX * scale)) : bmpX);
if (screenX >= getScreenWidth()) {
break;
}
if (screenX < 0) {
continue;
}
// Get 2-bit value (result of readRow quantization)
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
// For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3)
// val < 3 means black pixel (draw it)
if (val < 3) {
drawPixel(screenX, screenY, true);
}
// White pixels (val == 3) are not drawn (leave background)
}
}
free(outputRow);
free(rowBytes);
}
void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const {
if (numPoints < 3) return;
// Find bounding box
int minY = yPoints[0], maxY = yPoints[0];
for (int i = 1; i < numPoints; i++) {
if (yPoints[i] < minY) minY = yPoints[i];
if (yPoints[i] > maxY) maxY = yPoints[i];
}
// Clip to screen
if (minY < 0) minY = 0;
if (maxY >= getScreenHeight()) maxY = getScreenHeight() - 1;
// Allocate node buffer for scanline algorithm
auto* nodeX = static_cast<int*>(malloc(numPoints * sizeof(int)));
if (!nodeX) {
Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", millis());
return;
}
// Scanline fill algorithm
for (int scanY = minY; scanY <= maxY; scanY++) {
int nodes = 0;
// Find all intersection points with edges
int j = numPoints - 1;
for (int i = 0; i < numPoints; i++) {
if ((yPoints[i] < scanY && yPoints[j] >= scanY) || (yPoints[j] < scanY && yPoints[i] >= scanY)) {
// Calculate X intersection using fixed-point to avoid float
int dy = yPoints[j] - yPoints[i];
if (dy != 0) {
nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) * (xPoints[j] - xPoints[i]) / dy;
}
}
j = i;
}
// Sort nodes by X (simple bubble sort, numPoints is small)
for (int i = 0; i < nodes - 1; i++) {
for (int k = i + 1; k < nodes; k++) {
if (nodeX[i] > nodeX[k]) {
int temp = nodeX[i];
nodeX[i] = nodeX[k];
nodeX[k] = temp;
}
}
}
// Fill between pairs of nodes
for (int i = 0; i < nodes - 1; i += 2) {
int startX = nodeX[i];
int endX = nodeX[i + 1];
// Clip to screen
if (startX < 0) startX = 0;
if (endX >= getScreenWidth()) endX = getScreenWidth() - 1;
// Draw horizontal line
for (int x = startX; x <= endX; x++) {
drawPixel(x, scanY, state);
}
}
}
free(nodeX);
}
void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); }
void GfxRenderer::invertScreen() const {
@ -577,6 +436,29 @@ void GfxRenderer::restoreBwBuffer() {
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis());
}
bool GfxRenderer::copyStoredBwBuffer() {
// Check if all chunks are allocated
for (const auto& bwBufferChunk : bwBufferChunks) {
if (!bwBufferChunk) {
return false;
}
}
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
if (!frameBuffer) {
return false;
}
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
const size_t offset = i * BW_BUFFER_CHUNK_SIZE;
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
}
return true;
}
void GfxRenderer::freeStoredBwBuffer() { freeBwBufferChunks(); }
/**
* Copy stored BW buffer to framebuffer without freeing the stored chunks.
* Use this when you want to restore the buffer but keep it for later reuse.

View File

@ -67,8 +67,6 @@ class GfxRenderer {
void fillRect(int x, int y, int width, int height, bool state = true) const;
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;
// Text
int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;

View File

@ -114,96 +114,6 @@ static inline uint8_t quantize(int gray, int x, int y) {
}
}
// 1-bit noise dithering for fast home screen rendering
// Uses hash-based noise for consistent dithering that works well at small sizes
static inline uint8_t quantize1bit(int gray, int x, int y) {
gray = adjustPixel(gray);
// Generate noise threshold using integer hash (no regular pattern to alias)
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24); // 0-255
// Simple threshold with noise: gray >= (128 + noise offset) -> white
// The noise adds variation around the 128 midpoint
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
return (gray >= adjustedThreshold) ? 1 : 0;
}
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):
// X 1/8 1/8
// 1/8 1/8 1/8
// 1/8
class Atkinson1BitDitherer {
public:
Atkinson1BitDitherer(int width) : width(width) {
errorRow0 = new int16_t[width + 4](); // Current row
errorRow1 = new int16_t[width + 4](); // Next row
errorRow2 = new int16_t[width + 4](); // Row after next
}
~Atkinson1BitDitherer() {
delete[] errorRow0;
delete[] errorRow1;
delete[] errorRow2;
}
uint8_t processPixel(int gray, int x) {
// Apply brightness/contrast/gamma adjustments
gray = adjustPixel(gray);
// Add accumulated error
int adjusted = gray + errorRow0[x + 2];
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 2 levels (1-bit): 0 = black, 1 = white
uint8_t quantized;
int quantizedValue;
if (adjusted < 128) {
quantized = 0;
quantizedValue = 0;
} else {
quantized = 1;
quantizedValue = 255;
}
// Calculate error (only distribute 6/8 = 75%)
int error = (adjusted - quantizedValue) >> 3; // error/8
// Distribute 1/8 to each of 6 neighbors
errorRow0[x + 3] += error; // Right
errorRow0[x + 4] += error; // Right+1
errorRow1[x + 1] += error; // Bottom-left
errorRow1[x + 2] += error; // Bottom
errorRow1[x + 3] += error; // Bottom-right
errorRow2[x + 2] += error; // Two rows down
return quantized;
}
void nextRow() {
int16_t* temp = errorRow0;
errorRow0 = errorRow1;
errorRow1 = errorRow2;
errorRow2 = temp;
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
void reset() {
memset(errorRow0, 0, (width + 4) * sizeof(int16_t));
memset(errorRow1, 0, (width + 4) * sizeof(int16_t));
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
private:
int width;
int16_t* errorRow0;
int16_t* errorRow1;
int16_t* errorRow2;
};
// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results
// Error distribution pattern:
// X 1/8 1/8
@ -445,45 +355,6 @@ void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) {
}
}
// Helper function: Write BMP header with 1-bit color depth (black and white)
static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes)
const int bytesPerRow = (width + 31) / 32 * 4; // 1 bit per pixel, round up to 4-byte boundary
const int imageSize = bytesPerRow * height;
const uint32_t fileSize = 62 + imageSize; // 14 (file header) + 40 (DIB header) + 8 (palette) + image
// BMP File Header (14 bytes)
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize); // File size
write32(bmpOut, 0); // Reserved
write32(bmpOut, 62); // Offset to pixel data (14 + 40 + 8)
// DIB Header (BITMAPINFOHEADER - 40 bytes)
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height); // Negative height = top-down bitmap
write16(bmpOut, 1); // Color planes
write16(bmpOut, 1); // Bits per pixel (1 bit)
write32(bmpOut, 0); // BI_RGB (no compression)
write32(bmpOut, imageSize);
write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI)
write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI)
write32(bmpOut, 2); // colorsUsed
write32(bmpOut, 2); // colorsImportant
// Color Palette (2 colors x 4 bytes = 8 bytes)
// Format: Blue, Green, Red, Reserved (BGRA)
// Note: In 1-bit BMP, palette index 0 = black, 1 = white
uint8_t palette[8] = {
0x00, 0x00, 0x00, 0x00, // Color 0: Black
0xFF, 0xFF, 0xFF, 0x00 // Color 1: White
};
for (const uint8_t i : palette) {
bmpOut.write(i);
}
}
// Helper function: Write BMP header with 2-bit color depth
static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes)
@ -556,11 +427,10 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un
return 0; // Success
}
// Internal implementation with configurable target size and bit depth
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit) {
Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit",
targetWidth, targetHeight);
// Internal implementation with configurable target size
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth,
int targetHeight) {
Serial.printf("[%lu] [JPG] Converting JPEG to BMP (target: %dx%d)\n", millis(), targetWidth, targetHeight);
// Setup context for picojpeg callback
JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
@ -620,12 +490,9 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
// Write BMP header with output dimensions
int bytesPerRow;
if (USE_8BIT_OUTPUT && !oneBit) {
if (USE_8BIT_OUTPUT) {
writeBmpHeader8bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 3) / 4 * 4;
} else if (oneBit) {
writeBmpHeader1bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel
} else {
writeBmpHeader2bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth * 2 + 31) / 32 * 4;
@ -658,16 +525,11 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
return false;
}
// Create ditherer if enabled
// Create ditherer if enabled (only for 2-bit output)
// Use OUTPUT dimensions for dithering (after prescaling)
AtkinsonDitherer* atkinsonDitherer = nullptr;
FloydSteinbergDitherer* fsDitherer = nullptr;
Atkinson1BitDitherer* atkinson1BitDitherer = nullptr;
if (oneBit) {
// For 1-bit output, use Atkinson dithering for better quality
atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth);
} else if (!USE_8BIT_OUTPUT) {
if (!USE_8BIT_OUTPUT) {
if (USE_ATKINSON) {
atkinsonDitherer = new AtkinsonDitherer(outWidth);
} else if (USE_FLOYD_STEINBERG) {
@ -753,25 +615,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
// No scaling - direct output (1:1 mapping)
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT && !oneBit) {
if (USE_8BIT_OUTPUT) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
rowBuffer[x] = adjustPixel(gray);
}
} else if (oneBit) {
// 1-bit output with Atkinson dithering for better quality
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
const uint8_t bit =
atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y);
// Pack 1-bit value: MSB first, 8 pixels per byte
const int byteIndex = x / 8;
const int bitOffset = 7 - (x % 8);
rowBuffer[byteIndex] |= (bit << bitOffset);
}
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
} else {
// 2-bit output
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
uint8_t twoBit;
@ -829,25 +678,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) {
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT && !oneBit) {
if (USE_8BIT_OUTPUT) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
rowBuffer[x] = adjustPixel(gray);
}
} else if (oneBit) {
// 1-bit output with Atkinson dithering for better quality
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x)
: quantize1bit(gray, x, currentOutY);
// Pack 1-bit value: MSB first, 8 pixels per byte
const int byteIndex = x / 8;
const int bitOffset = 7 - (x % 8);
rowBuffer[byteIndex] |= (bit << bitOffset);
}
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
} else {
// 2-bit output
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
uint8_t twoBit;
@ -895,9 +731,6 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
if (fsDitherer) {
delete fsDitherer;
}
if (atkinson1BitDitherer) {
delete atkinson1BitDitherer;
}
free(mcuRowBuffer);
free(rowBuffer);
@ -907,17 +740,11 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
// Core function: Convert JPEG file to 2-bit BMP (uses default target size)
bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false);
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT);
}
// Convert with custom target size (for thumbnails, 2-bit)
// Convert with custom target size (for thumbnails)
bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
int targetMaxHeight) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false);
}
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
int targetMaxHeight) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true);
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight);
}

View File

@ -5,15 +5,13 @@ class Print;
class ZipFile;
class JpegToBmpConverter {
// [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y);
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit);
static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight);
public:
static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut);
// Convert with custom target size (for thumbnails)
static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
};

View File

@ -383,7 +383,7 @@ bool Xtc::generateThumbBmp() const {
return false;
}
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
// Create thumbnail BMP file - use 2-bit format like EPUB covers
FsFile thumbBmp;
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) {
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis());
@ -391,10 +391,10 @@ bool Xtc::generateThumbBmp() const {
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
// Write 2-bit BMP header (same format as JpegToBmpConverter)
const uint32_t rowSize = (thumbWidth * 2 + 31) / 32 * 4; // 2 bits per pixel, aligned
const uint32_t imageSize = rowSize * thumbHeight;
const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette
const uint32_t fileSize = 14 + 40 + 16 + imageSize; // 16 bytes for 4-color palette
// File header
thumbBmp.write('B');
@ -402,7 +402,7 @@ bool Xtc::generateThumbBmp() const {
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)
uint32_t dataOffset = 14 + 40 + 16; // 2-bit palette has 4 colors (16 bytes)
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
// DIB header
@ -414,7 +414,7 @@ bool Xtc::generateThumbBmp() const {
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
uint16_t bitsPerPixel = 2; // 2-bit for 4 grayscale levels
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
uint32_t compression = 0;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
@ -423,19 +423,22 @@ bool Xtc::generateThumbBmp() const {
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;
uint32_t colorsUsed = 4;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
uint32_t colorsImportant = 2;
uint32_t colorsImportant = 4;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
// Color palette (2 colors for 1-bit: black and white)
uint8_t palette[8] = {
// Color palette (4 colors for 2-bit, same as JpegToBmpConverter)
uint8_t palette[16] = {
0x00, 0x00, 0x00, 0x00, // Color 0: Black
0xFF, 0xFF, 0xFF, 0x00 // Color 1: White
0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85)
0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170)
0xFF, 0xFF, 0xFF, 0x00 // Color 3: White
};
thumbBmp.write(palette, 8);
thumbBmp.write(palette, 16);
// Allocate row buffer for 1-bit output
// Allocate row buffer for 2-bit output
const size_t dstRowSize = (thumbWidth * 2 + 7) / 8;
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(rowSize));
if (!rowBuffer) {
free(pageBuffer);
@ -454,7 +457,7 @@ bool Xtc::generateThumbBmp() const {
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)
memset(rowBuffer, 0xFF, rowSize); // Start with all white (color 3)
// Calculate source Y range with bounds checking
uint32_t srcYStart = (static_cast<uint32_t>(dstY) * scaleInv_fp) >> 16;
@ -516,28 +519,28 @@ bool Xtc::generateThumbBmp() const {
}
}
// Calculate average grayscale and quantize to 1-bit with noise dithering
// Calculate average grayscale and quantize to 2-bit
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 4 levels (same thresholds as JpegToBmpConverter)
uint8_t twoBit;
if (avgGray < 43) {
twoBit = 0; // Black
} else if (avgGray < 128) {
twoBit = 1; // Dark gray
} else if (avgGray < 213) {
twoBit = 2; // Light gray
} else {
twoBit = 3; // White
}
// 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);
// Pack 2-bit value into row buffer (MSB first)
const size_t byteIndex = (dstX * 2) / 8;
const size_t bitOffset = 6 - ((dstX * 2) % 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
}
rowBuffer[byteIndex] &= ~(0x03 << bitOffset); // Clear bits
rowBuffer[byteIndex] |= (twoBit << bitOffset); // Set bits
}
}

View File

@ -211,18 +211,12 @@ void HomeActivity::render() {
constexpr int bookY = 30;
const bool bookSelected = hasContinueReading && selectorIndex == 0;
// Bookmark dimensions (used in multiple places)
const int bookmarkWidth = bookWidth / 8;
const int bookmarkHeight = bookHeight / 5;
const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10;
const int bookmarkY = bookY + 5;
// Draw book card regardless, fill with message based on `hasContinueReading`
{
// Draw cover image as background if available (inside the box)
// Only load from SD on first render, then use stored buffer
if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) {
// First time: load cover from SD and render
// First time: load cover from SD and store buffer
FsFile file;
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
@ -252,39 +246,9 @@ void HomeActivity::render() {
// Draw border around the card
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
// Draw bookmark ribbon immediately after cover
const int notchDepth = bookmarkHeight / 3;
const int centerX = bookmarkX + bookmarkWidth / 2;
const int xPoints[5] = {
bookmarkX, // top-left
bookmarkX + bookmarkWidth, // top-right
bookmarkX + bookmarkWidth, // bottom-right
centerX, // center notch point
bookmarkX // bottom-left
};
const int yPoints[5] = {
bookmarkY, // top-left
bookmarkY, // top-right
bookmarkY + bookmarkHeight, // bottom-right
bookmarkY + bookmarkHeight - notchDepth, // center notch point
bookmarkY + bookmarkHeight // bottom-left
};
// Draw bookmark ribbon (white normally, will be inverted if selected)
renderer.fillPolygon(xPoints, yPoints, 5, false);
// Store the buffer with cover image AND bookmark for fast navigation
coverBufferStored = renderer.storeBwBuffer();
coverRendered = true;
// First render: if selected, draw selection indicators now
if (bookSelected) {
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
// Invert bookmark to black
renderer.fillPolygon(xPoints, yPoints, 5, true);
}
// Store the buffer with cover image for fast navigation
coverBufferStored = renderer.storeBwBuffer();
}
file.close();
}
@ -297,33 +261,10 @@ void HomeActivity::render() {
}
}
// If buffer was restored, draw selection indicators if needed
if (bufferRestored && bookSelected && coverRendered) {
// Draw selection border
// If buffer was restored, just draw selection border if needed
if (bufferRestored && bookSelected) {
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
// Invert bookmark color when selected (draw black over the white bookmark)
const int notchDepth = bookmarkHeight / 3;
const int centerX = bookmarkX + bookmarkWidth / 2;
const int xPoints[5] = {
bookmarkX, // top-left
bookmarkX + bookmarkWidth, // top-right
bookmarkX + bookmarkWidth, // bottom-right
centerX, // center notch point
bookmarkX // bottom-left
};
const int yPoints[5] = {
bookmarkY, // top-left
bookmarkY, // top-right
bookmarkY + bookmarkHeight, // bottom-right
bookmarkY + bookmarkHeight - notchDepth, // center notch point
bookmarkY + bookmarkHeight // bottom-left
};
// Draw black filled bookmark ribbon (inverted)
renderer.fillPolygon(xPoints, yPoints, 5, true);
} else if (!coverRendered) {
// No cover: draw border for non-cover case
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
@ -332,6 +273,33 @@ void HomeActivity::render() {
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
}
}
// Bookmark icon in the top-right corner of the card (inside the box)
// Skip if buffer was restored (bookmark is already in the buffer)
if (!bufferRestored) {
const int bookmarkWidth = bookWidth / 8;
const int bookmarkHeight = bookHeight / 5;
const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10;
const int bookmarkY = bookY + 5;
// Main bookmark body (solid) - white on cover, inverted on selection
const bool bookmarkWhite = coverRendered ? true : !bookSelected;
renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, bookmarkWhite);
// Carve out an inverted triangle notch at the bottom center to create angled points
const int notchHeight = bookmarkHeight / 2; // depth of the notch
const bool notchColor = coverRendered ? false : bookSelected;
for (int i = 0; i < notchHeight; ++i) {
const int y = bookmarkY + bookmarkHeight - 1 - i;
const int xStart = bookmarkX + i;
const int width = bookmarkWidth - 2 * i;
if (width <= 0) {
break;
}
// Draw a horizontal strip in the opposite color to "cut" the notch
renderer.fillRect(xStart, y, width, 1, notchColor);
}
}
}
if (hasContinueReading) {