feat(home): Improve Continue Reading cover with 1-bit Atkinson dithering

- Add 1-bit BMP generation with Atkinson dithering for better quality thumbnails
- Replace noise dithering with error diffusion for smoother gradients
- Add fillPolygon() to GfxRenderer for proper bookmark ribbon shape
- Change bookmark from rect+triangle carve to pentagon polygon
- Fix bookmark inversion when Continue Reading card is selected
- Show selection state on first render (not just after navigation)
- Fix 1-bit BMP palette lookup in Bitmap::readRow()
- Add drawBitmap1Bit() optimized path for 1-bit BMPs
The 1-bit format eliminates gray passes on home screen for faster rendering
while Atkinson dithering maintains good image quality through error diffusion.
This commit is contained in:
Eunchurn Park 2026-01-05 00:47:42 +09:00
parent 6fbdd06101
commit fbda7aa4f1
No known key found for this signature in database
GPG Key ID: 29D94D9C697E3F92
9 changed files with 433 additions and 105 deletions

View File

@ -369,10 +369,11 @@ bool Epub::generateThumbBmp() const {
return false; return false;
} }
// Use smaller target size for Continue Reading card (half of screen: 240x400) // 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_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400; constexpr int THUMB_TARGET_HEIGHT = 400;
const bool success = const bool success =
JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT); JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
coverJpg.close(); coverJpg.close();
thumbBmp.close(); thumbBmp.close();
SdMan.remove(coverJpgTempPath.c_str()); SdMan.remove(coverJpgTempPath.c_str());

View File

@ -341,7 +341,10 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) cons
} }
case 1: { case 1: {
for (int x = 0; x < width; x++) { for (int x = 0; x < width; x++) {
lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; // 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];
packPixel(lum); packPixel(lum);
} }
break; break;

View File

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

View File

@ -154,6 +154,12 @@ 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, void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
const int maxHeight) const { 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; float scale = 1.0f;
bool isScaled = false; bool isScaled = false;
if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
@ -222,6 +228,141 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
free(rowBytes); 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::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); }
void GfxRenderer::invertScreen() const { void GfxRenderer::invertScreen() const {
@ -436,29 +577,6 @@ void GfxRenderer::restoreBwBuffer() {
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); 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. * 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. * Use this when you want to restore the buffer but keep it for later reuse.

View File

@ -67,6 +67,8 @@ class GfxRenderer {
void fillRect(int x, int y, int width, int height, bool state = true) const; 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 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 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 // Text
int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;

View File

@ -114,6 +114,96 @@ 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 // Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results
// Error distribution pattern: // Error distribution pattern:
// X 1/8 1/8 // X 1/8 1/8
@ -355,6 +445,45 @@ 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 // Helper function: Write BMP header with 2-bit color depth
static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) { static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes) // Calculate row padding (each row must be multiple of 4 bytes)
@ -427,10 +556,11 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un
return 0; // Success return 0; // Success
} }
// Internal implementation with configurable target size // Internal implementation with configurable target size and bit depth
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
int targetHeight) { bool oneBit) {
Serial.printf("[%lu] [JPG] Converting JPEG to BMP (target: %dx%d)\n", millis(), targetWidth, targetHeight); Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit",
targetWidth, targetHeight);
// Setup context for picojpeg callback // Setup context for picojpeg callback
JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
@ -490,9 +620,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
// Write BMP header with output dimensions // Write BMP header with output dimensions
int bytesPerRow; int bytesPerRow;
if (USE_8BIT_OUTPUT) { if (USE_8BIT_OUTPUT && !oneBit) {
writeBmpHeader8bit(bmpOut, outWidth, outHeight); writeBmpHeader8bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 3) / 4 * 4; bytesPerRow = (outWidth + 3) / 4 * 4;
} else if (oneBit) {
writeBmpHeader1bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel
} else { } else {
writeBmpHeader2bit(bmpOut, outWidth, outHeight); writeBmpHeader2bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth * 2 + 31) / 32 * 4; bytesPerRow = (outWidth * 2 + 31) / 32 * 4;
@ -525,11 +658,16 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
return false; return false;
} }
// Create ditherer if enabled (only for 2-bit output) // Create ditherer if enabled
// Use OUTPUT dimensions for dithering (after prescaling) // Use OUTPUT dimensions for dithering (after prescaling)
AtkinsonDitherer* atkinsonDitherer = nullptr; AtkinsonDitherer* atkinsonDitherer = nullptr;
FloydSteinbergDitherer* fsDitherer = nullptr; FloydSteinbergDitherer* fsDitherer = nullptr;
if (!USE_8BIT_OUTPUT) { 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_ATKINSON) { if (USE_ATKINSON) {
atkinsonDitherer = new AtkinsonDitherer(outWidth); atkinsonDitherer = new AtkinsonDitherer(outWidth);
} else if (USE_FLOYD_STEINBERG) { } else if (USE_FLOYD_STEINBERG) {
@ -615,12 +753,24 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
// No scaling - direct output (1:1 mapping) // No scaling - direct output (1:1 mapping)
memset(rowBuffer, 0, bytesPerRow); memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT) { if (USE_8BIT_OUTPUT && !oneBit) {
for (int x = 0; x < outWidth; x++) { for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
rowBuffer[x] = adjustPixel(gray); 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 { } else {
// 2-bit output
for (int x = 0; x < outWidth; x++) { for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
uint8_t twoBit; uint8_t twoBit;
@ -678,12 +828,24 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) { if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) {
memset(rowBuffer, 0, bytesPerRow); memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT) { if (USE_8BIT_OUTPUT && !oneBit) {
for (int x = 0; x < outWidth; x++) { for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
rowBuffer[x] = adjustPixel(gray); 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 { } else {
// 2-bit output
for (int x = 0; x < outWidth; x++) { for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
uint8_t twoBit; uint8_t twoBit;
@ -731,6 +893,9 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
if (fsDitherer) { if (fsDitherer) {
delete fsDitherer; delete fsDitherer;
} }
if (atkinson1BitDitherer) {
delete atkinson1BitDitherer;
}
free(mcuRowBuffer); free(mcuRowBuffer);
free(rowBuffer); free(rowBuffer);
@ -740,11 +905,17 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
// Core function: Convert JPEG file to 2-bit BMP (uses default target size) // Core function: Convert JPEG file to 2-bit BMP (uses default target size)
bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT); return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false);
} }
// Convert with custom target size (for thumbnails) // Convert with custom target size (for thumbnails, 2-bit)
bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
int targetMaxHeight) { int targetMaxHeight) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, 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);
} }

View File

@ -5,13 +5,15 @@ class Print;
class ZipFile; class ZipFile;
class JpegToBmpConverter { 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, static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data); unsigned char* pBytes_actually_read, void* pCallback_data);
static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight); static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit);
public: public:
static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut); static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut);
// Convert with custom target size (for thumbnails) // Convert with custom target size (for thumbnails)
static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); 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; return false;
} }
// Create thumbnail BMP file - use 2-bit format like EPUB covers // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
FsFile thumbBmp; FsFile thumbBmp;
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) { if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) {
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis());
@ -391,10 +391,10 @@ bool Xtc::generateThumbBmp() const {
return false; return false;
} }
// Write 2-bit BMP header (same format as JpegToBmpConverter) // Write 1-bit BMP header for fast home screen rendering
const uint32_t rowSize = (thumbWidth * 2 + 31) / 32 * 4; // 2 bits per pixel, aligned 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 imageSize = rowSize * thumbHeight;
const uint32_t fileSize = 14 + 40 + 16 + imageSize; // 16 bytes for 4-color palette const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette
// File header // File header
thumbBmp.write('B'); thumbBmp.write('B');
@ -402,7 +402,7 @@ bool Xtc::generateThumbBmp() const {
thumbBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&fileSize), 4);
uint32_t reserved = 0; uint32_t reserved = 0;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&reserved), 4);
uint32_t dataOffset = 14 + 40 + 16; // 2-bit palette has 4 colors (16 bytes) uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes)
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&dataOffset), 4);
// DIB header // DIB header
@ -414,7 +414,7 @@ bool Xtc::generateThumbBmp() const {
thumbBmp.write(reinterpret_cast<const uint8_t*>(&heightVal), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&heightVal), 4);
uint16_t planes = 1; uint16_t planes = 1;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2); thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
uint16_t bitsPerPixel = 2; // 2-bit for 4 grayscale levels uint16_t bitsPerPixel = 1; // 1-bit for black and white
thumbBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2); thumbBmp.write(reinterpret_cast<const uint8_t*>(&bitsPerPixel), 2);
uint32_t compression = 0; uint32_t compression = 0;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&compression), 4);
@ -423,22 +423,19 @@ bool Xtc::generateThumbBmp() const {
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmX), 4);
int32_t ppmY = 2835; int32_t ppmY = 2835;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&ppmY), 4);
uint32_t colorsUsed = 4; uint32_t colorsUsed = 2;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsUsed), 4);
uint32_t colorsImportant = 4; uint32_t colorsImportant = 2;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
// Color palette (4 colors for 2-bit, same as JpegToBmpConverter) // Color palette (2 colors for 1-bit: black and white)
uint8_t palette[16] = { uint8_t palette[8] = {
0x00, 0x00, 0x00, 0x00, // Color 0: Black 0x00, 0x00, 0x00, 0x00, // Color 0: Black
0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85) 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White
0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170)
0xFF, 0xFF, 0xFF, 0x00 // Color 3: White
}; };
thumbBmp.write(palette, 16); thumbBmp.write(palette, 8);
// Allocate row buffer for 2-bit output // Allocate row buffer for 1-bit output
const size_t dstRowSize = (thumbWidth * 2 + 7) / 8;
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(rowSize)); uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(rowSize));
if (!rowBuffer) { if (!rowBuffer) {
free(pageBuffer); free(pageBuffer);
@ -457,7 +454,7 @@ bool Xtc::generateThumbBmp() const {
const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0; const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0;
for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) {
memset(rowBuffer, 0xFF, rowSize); // Start with all white (color 3) memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1)
// Calculate source Y range with bounds checking // Calculate source Y range with bounds checking
uint32_t srcYStart = (static_cast<uint32_t>(dstY) * scaleInv_fp) >> 16; uint32_t srcYStart = (static_cast<uint32_t>(dstY) * scaleInv_fp) >> 16;
@ -519,28 +516,28 @@ bool Xtc::generateThumbBmp() const {
} }
} }
// Calculate average grayscale and quantize to 2-bit // Calculate average grayscale and quantize to 1-bit with noise dithering
uint8_t avgGray = (totalCount > 0) ? static_cast<uint8_t>(graySum / totalCount) : 255; uint8_t avgGray = (totalCount > 0) ? static_cast<uint8_t>(graySum / totalCount) : 255;
// Quantize to 4 levels (same thresholds as JpegToBmpConverter) // Hash-based noise dithering for 1-bit output
uint8_t twoBit; uint32_t hash = static_cast<uint32_t>(dstX) * 374761393u + static_cast<uint32_t>(dstY) * 668265263u;
if (avgGray < 43) { hash = (hash ^ (hash >> 13)) * 1274126177u;
twoBit = 0; // Black const int threshold = static_cast<int>(hash >> 24); // 0-255
} else if (avgGray < 128) { const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
twoBit = 1; // Dark gray
} else if (avgGray < 213) {
twoBit = 2; // Light gray
} else {
twoBit = 3; // White
}
// Pack 2-bit value into row buffer (MSB first) // Quantize to 1-bit: 0=black, 1=white
const size_t byteIndex = (dstX * 2) / 8; uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0;
const size_t bitOffset = 6 - ((dstX * 2) % 8);
// 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 // Bounds check for row buffer access
if (byteIndex < rowSize) { if (byteIndex < rowSize) {
rowBuffer[byteIndex] &= ~(0x03 << bitOffset); // Clear bits if (oneBit) {
rowBuffer[byteIndex] |= (twoBit << bitOffset); // Set bits rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white
} else {
rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black
}
} }
} }

View File

@ -211,12 +211,18 @@ void HomeActivity::render() {
constexpr int bookY = 30; constexpr int bookY = 30;
const bool bookSelected = hasContinueReading && selectorIndex == 0; 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 book card regardless, fill with message based on `hasContinueReading`
{ {
// Draw cover image as background if available (inside the box) // Draw cover image as background if available (inside the box)
// Only load from SD on first render, then use stored buffer // Only load from SD on first render, then use stored buffer
if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) { if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) {
// First time: load cover from SD and store buffer // First time: load cover from SD and render
FsFile file; FsFile file;
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
@ -246,9 +252,39 @@ void HomeActivity::render() {
// Draw border around the card // Draw border around the card
renderer.drawRect(bookX, bookY, bookWidth, bookHeight); renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
coverRendered = true; // Draw bookmark ribbon immediately after cover
// Store the buffer with cover image for fast navigation 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(); 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);
}
} }
file.close(); file.close();
} }
@ -261,10 +297,33 @@ void HomeActivity::render() {
} }
} }
// If buffer was restored, just draw selection border if needed // If buffer was restored, draw selection indicators if needed
if (bufferRestored && bookSelected) { if (bufferRestored && bookSelected && coverRendered) {
// Draw selection border
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); 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) { } else if (!coverRendered) {
// No cover: draw border for non-cover case // No cover: draw border for non-cover case
renderer.drawRect(bookX, bookY, bookWidth, bookHeight); renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
@ -273,33 +332,6 @@ void HomeActivity::render() {
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); 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) { if (hasContinueReading) {