diff --git a/lib/Epub/Epub/blocks/ImageBlock.cpp b/lib/Epub/Epub/blocks/ImageBlock.cpp index 1b71817a89..d234887a0f 100644 --- a/lib/Epub/Epub/blocks/ImageBlock.cpp +++ b/lib/Epub/Epub/blocks/ImageBlock.cpp @@ -74,13 +74,13 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, } const int destY = y + row; - pw.beginRow(destY); + pw.beginRow(destY, x); for (int col = 0; col < cachedWidth; col++) { const int byteIdx = col >> 2; // col / 4 const int bitShift = 6 - (col & 3) * 2; // MSB first within byte uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03; - pw.writePixel(x + col, pixelValue); + pw.writePixel(pixelValue); } } diff --git a/lib/Epub/Epub/converters/DirectPixelWriter.h b/lib/Epub/Epub/converters/DirectPixelWriter.h index bc66c2f78e..44bb56fd09 100644 --- a/lib/Epub/Epub/converters/DirectPixelWriter.h +++ b/lib/Epub/Epub/converters/DirectPixelWriter.h @@ -6,16 +6,26 @@ // Direct framebuffer writer that eliminates per-pixel overhead from the image // rendering hot path. Pre-computes orientation transform as linear coefficients -// and caches render-mode state so the inner loop is: one multiply, one add, -// one shift, and one AND per pixel — no branches, no method calls. +// and caches render-mode state so the inner loop is: two increments, one shift, +// one AND, and one or two bit-writes per pixel — no multiplies, no branches on +// mode or orientation, no method calls. // -// Caller is responsible for ensuring (outX, outY) are within screen bounds. -// ImageBlock::render() already validates this before entering the pixel loop, -// and the JPEG/PNG callbacks pre-clamp destination ranges to screen bounds. +// Usage: +// pw.init(renderer); +// for each row: pw.beginRow(logicalY); // resets running X/Y state to col 0 +// for each col: pw.writePixel(value); // advances running state, writes bit +// +// Caller must call writePixel() for every column in order (0, 1, 2, …) because +// the running state is advanced unconditionally on each call. Caller is also +// responsible for ensuring columns are within screen bounds before entering the +// loop; no bounds checking is performed here. struct DirectPixelWriter { uint8_t* fb; - GfxRenderer::RenderMode mode; - uint16_t displayWidthBytes; // Runtime framebuffer stride (X4: 100, X3: 99) + uint8_t* fb2; // Secondary framebuffer for MSB plane (null = two-pass / not active) + uint8_t writeFbMask; // Bit i set → write to fb when pixelValue==i (pre-computed from render mode) + uint8_t writeFb2Mask; // Bit i set → write to fb2 when pixelValue==i + bool fbClearBit; // true = clear bit (BW black); false = set bit (all gray modes) + uint16_t displayWidthBytes; // Runtime framebuffer stride // Orientation is collapsed into a linear transform: // phyX = phyXBase + x * phyXStepX + y * phyXStepY @@ -24,14 +34,44 @@ struct DirectPixelWriter { int phyXStepX, phyYStepX; // per logical-X step int phyXStepY, phyYStepY; // per logical-Y step - // Row-precomputed: the Y-dependent portion of the physical coords - int rowPhyXBase, rowPhyYBase; + // Pre-computed once in init(): physical-Y advance per logical-X step (in byte-index units). + int32_t byteIdxYStep; + + // Running state — reset by beginRow(), advanced by writePixel(). + int curPhyX; + int32_t curByteIdx; - void init(GfxRenderer& renderer) { + void init(const GfxRenderer& renderer) { fb = renderer.getFrameBuffer(); - mode = renderer.getRenderMode(); + fb2 = renderer.getSecondaryFrameBuffer(); displayWidthBytes = renderer.getDisplayWidthBytes(); + // Pre-compute write masks once so the inner loop has zero mode branches. + writeFbMask = 0; + writeFb2Mask = 0; + fbClearBit = false; + switch (renderer.getRenderMode()) { + case GfxRenderer::BW: + writeFbMask = 0x3; + fbClearBit = true; + break; + case GfxRenderer::GRAYSCALE_MSB: + writeFbMask = 0x6; + break; + case GfxRenderer::GRAYSCALE_LSB: + writeFbMask = 0x2; + break; + case GfxRenderer::GRAY2_LSB: + writeFbMask = 0x5; + if (fb2) writeFb2Mask = 0x3; + break; + case GfxRenderer::GRAY2_MSB: + writeFbMask = 0x3; + break; + default: + break; + } + const int phyW = renderer.getDisplayWidth(); const int phyH = renderer.getDisplayHeight(); @@ -82,52 +122,47 @@ struct DirectPixelWriter { phyYStepY = 1; break; } + + // Per-column advance in physical-Y expressed as a byte-index delta. + byteIdxYStep = static_cast(phyYStepX) * static_cast(displayWidthBytes); } // Call once per row before the column loop. - // Pre-computes the Y-dependent portion so writePixel() only needs the X part. - inline void beginRow(int logicalY) { - rowPhyXBase = phyXBase + logicalY * phyXStepY; - rowPhyYBase = phyYBase + logicalY * phyYStepY; + // startLogicalX is the X coordinate of the first writePixel() call for this row (default 0). + // Running state is initialised at startLogicalX so every subsequent writePixel() call + // advances to startLogicalX+1, startLogicalX+2, … with zero per-pixel multiplies. + inline void beginRow(int logicalY, int startLogicalX = 0) { + const int rowPhyXBase = phyXBase + logicalY * phyXStepY; + const int rowPhyYBase = phyYBase + logicalY * phyYStepY; + curPhyX = rowPhyXBase + startLogicalX * phyXStepX; + curByteIdx = + static_cast(rowPhyYBase + startLogicalX * phyYStepX) * static_cast(displayWidthBytes); } - // Write a single 2-bit dithered pixel value to the framebuffer. - // Must be called after beginRow() for the current row. + // Write a single 2-bit pixel value to the framebuffer and advance to the next column. + // Must be called after beginRow() for the current row, for every column in order. // No bounds checking — caller guarantees coordinates are valid. - inline void writePixel(int logicalX, uint8_t pixelValue) const { - // Determine whether to draw based on render mode - bool draw; - bool state; - switch (mode) { - case GfxRenderer::BW: - draw = (pixelValue < 3); - state = true; - break; - case GfxRenderer::GRAYSCALE_MSB: - draw = (pixelValue == 1 || pixelValue == 2); - state = false; - break; - case GfxRenderer::GRAYSCALE_LSB: - draw = (pixelValue == 1); - state = false; - break; - default: - return; - } - - if (!draw) return; - - const int phyX = rowPhyXBase + logicalX * phyXStepX; - const int phyY = rowPhyYBase + logicalX * phyYStepX; - - const uint16_t byteIndex = phyY * displayWidthBytes + (phyX >> 3); + // No mode switch — write masks are pre-computed in init() and stored as members. + inline void writePixel(uint8_t pixelValue) { + const int phyX = curPhyX; + const int32_t byteIdx = curByteIdx; + curPhyX += phyXStepX; + curByteIdx += byteIdxYStep; + + const bool doFb = (writeFbMask >> pixelValue) & 1; + const bool doFb2 = (writeFb2Mask >> pixelValue) & 1; + if (!doFb && !doFb2) return; + + const uint32_t bi = static_cast(byteIdx) + static_cast(phyX >> 3); const uint8_t bitMask = 1 << (7 - (phyX & 7)); - if (state) { - fb[byteIndex] &= ~bitMask; // Clear bit (draw black) - } else { - fb[byteIndex] |= bitMask; // Set bit (draw white) + if (doFb) { + if (fbClearBit) + fb[bi] &= ~bitMask; + else + fb[bi] |= bitMask; } + if (doFb2) fb2[bi] |= bitMask; } }; diff --git a/lib/Epub/Epub/converters/DitherUtils.h b/lib/Epub/Epub/converters/DitherUtils.h index ec63a76840..f8614047da 100644 --- a/lib/Epub/Epub/converters/DitherUtils.h +++ b/lib/Epub/Epub/converters/DitherUtils.h @@ -13,15 +13,30 @@ inline const uint8_t bayer4x4[4][4] = { // Apply Bayer dithering and quantize to 4 levels (0-3) // Stateless - works correctly with any pixel processing order inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) { + // Soft-shoulder darkening for factory LUT: EPUB image pages render on the + // factory LUT, where palette levels are physically lighter than on the + // differential LUT. Apply a -8 offset to mid-bright pixels onward to bring + // highlights/midtones back down without crushing deep shadow detail. + // Ramp the offset from 0 to 8 across gray [0, 64], flat -8 above 64. + int g = gray; + int offset = (g < 64) ? g * 8 / 64 : 8; + g -= offset; + int bayer = bayer4x4[y & 3][x & 3]; int dither = (bayer - 8) * 5; // Scale to +/-40 (half of quantization step 85) - int adjusted = gray + dither; + int adjusted = g + dither; if (adjusted < 0) adjusted = 0; if (adjusted > 255) adjusted = 255; - if (adjusted < 64) return 0; - if (adjusted < 128) return 1; - if (adjusted < 192) return 2; + // T01=48: slightly above original 43, keeps shadow detail while allowing + // near-black pixels to lift to dark gray. + // T12=133: raised from 128 to push more mid-bright source pixels into the + // palette 1 / palette 2 dither zone for perceptual mid-gray. + // T23=218: raised from 192 to expand light-gray range, preserving + // highlight detail on the brighter factory LUT. + if (adjusted < 48) return 0; + if (adjusted < 133) return 1; + if (adjusted < 218) return 2; return 3; } diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp index b0863bb5c8..5bc0d42d84 100644 --- a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -176,7 +176,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { if (fineScaleFPX == FP_ONE && fineScaleFPY == FP_ONE) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; - pw.beginRow(outY); + pw.beginRow(outY, cfgX + dstXStart); if (caching) cw.beginRow(outY, ctx->config->y); const uint8_t* row = &pixels[(dstY - blockY) * stride]; for (int dstX = dstXStart; dstX < dstXEnd; dstX++) { @@ -189,7 +189,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { dithered = gray / 85; if (dithered > 3) dithered = 3; } - pw.writePixel(outX, dithered); + pw.writePixel(dithered); if (caching) cw.writePixel(outX, dithered); } } @@ -210,7 +210,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; - pw.beginRow(outY); + pw.beginRow(outY, cfgX + dstXStart); if (caching) cw.beginRow(outY, ctx->config->y); const int32_t srcFyFP = dstY * invScaleFPY; const int32_t fy = srcFyFP & FP_MASK; @@ -248,7 +248,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { dithered = gray / 85; if (dithered > 3) dithered = 3; } - pw.writePixel(outX, dithered); + pw.writePixel(dithered); if (caching) cw.writePixel(outX, dithered); } @@ -271,7 +271,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { dithered = gray / 85; if (dithered > 3) dithered = 3; } - pw.writePixel(outX, dithered); + pw.writePixel(dithered); if (caching) cw.writePixel(outX, dithered); } @@ -297,7 +297,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { dithered = gray / 85; if (dithered > 3) dithered = 3; } - pw.writePixel(outX, dithered); + pw.writePixel(dithered); if (caching) cw.writePixel(outX, dithered); } } @@ -307,7 +307,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { // === Nearest-neighbor (downscale: fineScale < 1.0) === for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; - pw.beginRow(outY); + pw.beginRow(outY, cfgX + dstXStart); if (caching) cw.beginRow(outY, ctx->config->y); const int32_t srcFyFP = dstY * invScaleFPY; int ly = (srcFyFP >> FP_SHIFT) - blockY; @@ -330,7 +330,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { dithered = gray / 85; if (dithered > 3) dithered = 3; } - pw.writePixel(outX, dithered); + pw.writePixel(dithered); if (caching) cw.writePixel(outX, dithered); } } diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp index 0cc1616abc..ce0a4ab7f0 100644 --- a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -197,7 +197,7 @@ int pngDrawCallback(PNGDRAW* pDraw) { // Pre-compute orientation and render-mode state once per row DirectPixelWriter pw; pw.init(*ctx->renderer); - pw.beginRow(outY); + pw.beginRow(outY, outXBase); DirectCacheWriter cw; if (caching) { @@ -220,7 +220,7 @@ int pngDrawCallback(PNGDRAW* pDraw) { ditheredGray = gray / 85; if (ditheredGray > 3) ditheredGray = 3; } - pw.writePixel(outX, ditheredGray); + pw.writePixel(ditheredGray); if (caching) cw.writePixel(outX, ditheredGray); } diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index 891190fd43..975b4b49cd 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -119,6 +119,8 @@ bool hasPngExtension(std::string_view fileName) { return checkFileExtension(file bool hasBmpExtension(std::string_view fileName) { return checkFileExtension(fileName, ".bmp"); } +bool hasPxcExtension(std::string_view fileName) { return checkFileExtension(fileName, ".pxc"); } + bool hasGifExtension(std::string_view fileName) { return checkFileExtension(fileName, ".gif"); } bool hasEpubExtension(std::string_view fileName) { return checkFileExtension(fileName, ".epub"); } diff --git a/lib/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h index 56c2c9878f..fdffef524c 100644 --- a/lib/FsHelpers/FsHelpers.h +++ b/lib/FsHelpers/FsHelpers.h @@ -34,6 +34,9 @@ inline bool hasPngExtension(const String& fileName) { // Check for .bmp extension (case-insensitive) bool hasBmpExtension(std::string_view fileName); +// Check for .pxc extension (case-insensitive) +bool hasPxcExtension(std::string_view fileName); + // Check for .gif extension (case-insensitive) bool hasGifExtension(std::string_view fileName); inline bool hasGifExtension(const String& fileName) { diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 776e52f3b2..6231cfc30f 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -1,5 +1,7 @@ #include "Bitmap.h" +#include + #include #include @@ -19,6 +21,33 @@ Bitmap::~Bitmap() { delete atkinsonDitherer; delete fsDitherer; + + free(readChunkBuf); +} + +int Bitmap::bufferedRead(uint8_t* dst, int n) const { + if (!readChunkBuf) { + readChunkBuf = static_cast(malloc(READ_CHUNK_SIZE)); + if (!readChunkBuf) { + LOG_ERR("BMP", "bufferedRead: malloc failed (%d bytes)", READ_CHUNK_SIZE); + return 0; + } + } + + int done = 0; + while (done < n) { + if (readChunkPos >= readChunkFill) { + readChunkFill = file.read(readChunkBuf, READ_CHUNK_SIZE); + readChunkPos = 0; + if (readChunkFill <= 0) break; + } + const int avail = readChunkFill - readChunkPos; + const int take = (avail < n - done) ? avail : n - done; + memcpy(dst + done, readChunkBuf + readChunkPos, take); + readChunkPos += take; + done += take; + } + return done; } uint16_t Bitmap::readLE16(FsFile& f) { @@ -180,7 +209,7 @@ BmpReaderError Bitmap::parseHeaders() { // packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' - if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; + if (bufferedRead(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; prevRowY += 1; @@ -193,16 +222,16 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { auto packPixel = [&](const uint8_t lum) { uint8_t color; if (atkinsonDitherer) { - color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX); + color = atkinsonDitherer->processPixel(lum, currentX); } else if (fsDitherer) { - color = fsDitherer->processPixel(adjustPixel(lum), currentX); + color = fsDitherer->processPixel(lum, currentX); } else { if (nativePalette) { - // Palette matches native gray levels: direct mapping (still apply brightness/contrast/gamma) - color = static_cast(adjustPixel(lum) >> 6); + // Palette matches native gray levels: direct 2-bit mapping + color = static_cast(lum >> 6); } else { // Non-native palette with dithering disabled: simple quantization - color = quantize(adjustPixel(lum), currentX, prevRowY); + color = quantize(lum, currentX, prevRowY); } } currentOutByte |= (color << bitShift); @@ -287,6 +316,10 @@ BmpReaderError Bitmap::rewindToData() const { return BmpReaderError::SeekPixelDataFailed; } + // Invalidate chunk buffer — file position changed, buffered bytes are stale. + readChunkPos = 0; + readChunkFill = 0; + // Reset dithering when rewinding if (fsDitherer) fsDitherer->reset(); if (atkinsonDitherer) atkinsonDitherer->reset(); diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index bba4189f29..58abdb6faf 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -81,6 +81,10 @@ class Bitmap { static uint16_t readLE16(FsFile& f); static uint32_t readLE32(FsFile& f); + // Reads exactly n bytes from the internal chunk buffer, refilling from SD as needed. + // Returns bytes actually read (< n on EOF/error). + int bufferedRead(uint8_t* dst, int n) const; + FsFile& file; bool dithering = false; int width = 0; @@ -100,4 +104,10 @@ class Bitmap { mutable AtkinsonDitherer* atkinsonDitherer = nullptr; mutable FloydSteinbergDitherer* fsDitherer = nullptr; + + // SD read-ahead buffer: rows are served from here; refilled from SD in READ_CHUNK_SIZE chunks. + static constexpr int READ_CHUNK_SIZE = 4096; // 8 SD sectors per fill — enables CMD18 multi-block + mutable uint8_t* readChunkBuf = nullptr; + mutable int readChunkPos = 0; + mutable int readChunkFill = 0; }; diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index dca059ec9e..96a9135c13 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -5,11 +5,14 @@ #include "Bitmap.h" +// LUT-aware quantization profile: false = factory (default), true = differential +bool g_differentialQuantize = false; + // Brightness/Contrast adjustments: -constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments -constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) -constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) -constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) +constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments +constexpr int BRIGHTNESS_BOOST = 0; // No boost — quality LUT already renders slightly lighter +constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) +constexpr float CONTRAST_FACTOR = 1.2f; // Contrast boost for 1-bit thumbnails (applied inside Atkinson1BitDitherer) constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering // Integer approximation of gamma correction (brightens midtones) @@ -52,18 +55,21 @@ int adjustPixel(int gray) { return gray; } -// Simple quantization without dithering - divide into 4 levels -// The thresholds are fine-tuned to the X4 display +// Simple quantization without dithering. +// Factory LUT (fast/quality): evenly-spaced thresholds — softer, linear drive. +// Differential LUT: calibrated thresholds from upstream — narrow darkGrey band, +// biased toward lighter levels to compensate for more aggressive drive. uint8_t quantizeSimple(int gray) { - if (gray < 45) { - return 0; - } else if (gray < 70) { - return 1; - } else if (gray < 140) { - return 2; - } else { + if (g_differentialQuantize) { + if (gray < 45) return 0; + if (gray < 70) return 1; + if (gray < 140) return 2; return 3; } + if (gray < 43) return 0; + if (gray < 128) return 1; + if (gray < 213) return 2; + return 3; } // Hash-based noise dithering - survives downsampling without moiré artifacts diff --git a/lib/GfxRenderer/BitmapHelpers.h b/lib/GfxRenderer/BitmapHelpers.h index d9d2d85544..e95a393360 100644 --- a/lib/GfxRenderer/BitmapHelpers.h +++ b/lib/GfxRenderer/BitmapHelpers.h @@ -11,6 +11,10 @@ uint8_t quantizeSimple(int gray); uint8_t quantize1bit(int gray, int x, int y); int adjustPixel(int gray); +// Set true when rendering for differential LUT to use calibrated thresholds. +// Set false (default) for factory LUT (fast/quality) — uses evenly-spaced thresholds. +extern bool g_differentialQuantize; + enum class BmpRowOrder { BottomUp, TopDown }; // Populates a 1-bit BMP header in the provided memory. @@ -128,35 +132,38 @@ class AtkinsonDitherer { if (adjusted > 255) adjusted = 255; // Quantize to 4 levels + // Differential LUT: calibrated thresholds matching upstream (narrow darkGrey band, + // biased toward light to compensate for more aggressive drive). + // Factory LUT (fast/quality): evenly-spaced thresholds — softer drive, linear response. uint8_t quantized; int quantizedValue; - if (false) { // original thresholds - if (adjusted < 43) { + if (g_differentialQuantize) { + if (adjusted < 45) { quantized = 0; quantizedValue = 0; - } else if (adjusted < 128) { + } else if (adjusted < 70) { quantized = 1; quantizedValue = 85; - } else if (adjusted < 213) { + } else if (adjusted < 140) { quantized = 2; quantizedValue = 170; } else { quantized = 3; quantizedValue = 255; } - } else { // fine-tuned to X4 eink display - if (adjusted < 30) { + } else { + if (adjusted < 43) { quantized = 0; - quantizedValue = 15; - } else if (adjusted < 50) { + quantizedValue = 0; + } else if (adjusted < 128) { quantized = 1; - quantizedValue = 30; - } else if (adjusted < 140) { + quantizedValue = 85; + } else if (adjusted < 213) { quantized = 2; - quantizedValue = 80; + quantizedValue = 170; } else { quantized = 3; - quantizedValue = 210; + quantizedValue = 255; } } @@ -231,36 +238,36 @@ class FloydSteinbergDitherer { if (adjusted < 0) adjusted = 0; if (adjusted > 255) adjusted = 255; - // Quantize to 4 levels (0, 85, 170, 255) + // Quantize to 4 levels — see Atkinson ditherer comment for threshold rationale uint8_t quantized; int quantizedValue; - if (false) { // original thresholds - if (adjusted < 43) { + if (g_differentialQuantize) { + if (adjusted < 45) { quantized = 0; quantizedValue = 0; - } else if (adjusted < 128) { + } else if (adjusted < 70) { quantized = 1; quantizedValue = 85; - } else if (adjusted < 213) { + } else if (adjusted < 140) { quantized = 2; quantizedValue = 170; } else { quantized = 3; quantizedValue = 255; } - } else { // fine-tuned to X4 eink display - if (adjusted < 30) { + } else { + if (adjusted < 43) { quantized = 0; - quantizedValue = 15; - } else if (adjusted < 50) { + quantizedValue = 0; + } else if (adjusted < 128) { quantized = 1; - quantizedValue = 30; - } else if (adjusted < 140) { + quantizedValue = 85; + } else if (adjusted < 213) { quantized = 2; - quantizedValue = 80; + quantizedValue = 170; } else { quantized = 3; - quantizedValue = 210; + quantizedValue = 255; } } diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 769402ef59..32ea4c0759 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1,5 +1,6 @@ #include "GfxRenderer.h" +#include #include #include #include @@ -8,6 +9,7 @@ #include +#include "BitmapHelpers.h" #include "FontCacheManager.h" namespace { @@ -21,6 +23,28 @@ const char* resolveVisualText(const char* text, std::string& visualBuffer, int p uint8_t resolveSdCardStyle(const SdCardFont& font, const EpdFontFamily::Style style) { return font.resolveStyle(static_cast(style)); } + +// Bundles every panel-level value derived from a caller-facing GrayscaleDriveMode, +// so the three grayscale entry points share one mapping instead of repeating ternaries. +struct GrayscaleDriveSpec { + GfxRenderer::RenderMode lsbMode; + GfxRenderer::RenderMode msbMode; + const unsigned char* lut; // nullptr for Differential + bool factoryMode; + bool differentialQuantize; +}; + +GrayscaleDriveSpec resolveGrayscaleDrive(GfxRenderer::GrayscaleDriveMode mode) { + switch (mode) { + case GfxRenderer::GrayscaleDriveMode::Differential: + return {GfxRenderer::GRAYSCALE_LSB, GfxRenderer::GRAYSCALE_MSB, nullptr, false, true}; + case GfxRenderer::GrayscaleDriveMode::FactoryFast: + return {GfxRenderer::GRAY2_LSB, GfxRenderer::GRAY2_MSB, lut_factory_fast, true, false}; + case GfxRenderer::GrayscaleDriveMode::FactoryQuality: + return {GfxRenderer::GRAY2_LSB, GfxRenderer::GRAY2_MSB, lut_factory_quality, true, false}; + } + return {GfxRenderer::GRAYSCALE_LSB, GfxRenderer::GRAYSCALE_MSB, nullptr, false, true}; +} } // namespace const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const { @@ -183,7 +207,15 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update renderer.drawPixel(screenX, screenY, false); } else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) { - // Dark gray + // Differential LSB: mark dark gray pixels only + renderer.drawPixel(screenX, screenY, false); + } else if (renderMode == GfxRenderer::GRAY2_LSB && !(bmpVal & 1)) { + // Factory absolute LSB (BW RAM): set BW=1 for Black(0) and LightGrey(2) + // clearScreen(0x00) base; drawPixel(false) sets bit to 1 + renderer.drawPixel(screenX, screenY, false); + } else if (renderMode == GfxRenderer::GRAY2_MSB && bmpVal < 2) { + // Factory absolute MSB (RED RAM): set RED=1 for Black(0) and DarkGrey(1) + // clearScreen(0x00) base; drawPixel(false) sets bit to 1 renderer.drawPixel(screenX, screenY, false); } } @@ -206,7 +238,11 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode const uint8_t bit_index = 7 - (pixelPosition & 7); if ((byte >> bit_index) & 1) { - renderer.drawPixel(screenX, screenY, pixelState); + // In GRAY2 modes the framebuffer convention is inverted vs BW: clearScreen(0x00) is + // background and drawPixel(false) marks active pixels. BW-convention callers pass + // pixelState=true for "black" — invert here so 1-bit UI glyphs stay visible. + const bool gray2 = renderMode == GfxRenderer::GRAY2_LSB || renderMode == GfxRenderer::GRAY2_MSB; + renderer.drawPixel(screenX, screenY, gray2 ? !pixelState : pixelState); } } } @@ -225,7 +261,7 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { // Bounds checking against runtime panel dimensions if (phyX < 0 || phyX >= panelWidth || phyY < 0 || phyY >= panelHeight) { - LOG_ERR("GFX", "!! Outside range (%d, %d) -> (%d, %d)", x, y, phyX, phyY); + LOG_DBG("GFX", "!! Outside range (%d, %d) -> (%d, %d)", x, y, phyX, phyY); return; } @@ -235,11 +271,35 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { if (state) { frameBuffer[byteIndex] &= ~(1 << bitPosition); // Clear bit + // Single-pass: erasing a pixel must also clear the MSB plane so UI white fills (e.g. button + // hint backgrounds drawn on top of a full-screen image) fully erase image bits from both + // planes. Without this, image pixels remain in RED RAM and bleed through white areas. + if (renderMode == GRAY2_LSB && secondaryFrameBuffer != nullptr) { + secondaryFrameBuffer[byteIndex] &= ~(1 << bitPosition); + } } else { frameBuffer[byteIndex] |= 1 << bitPosition; // Set bit + // Single-pass: all set-bit draws in GRAY2_LSB mode (1-bit UI elements, text, icons) are + // treated as fully black and mirrored to the MSB plane so they don't render as light gray. + // Image pixels skip drawPixel and go through drawPixelToBuffer directly (see drawBitmap), + // so this path is only hit by 1-bit rendering (renderChar, drawIcon, etc.). + if (renderMode == GRAY2_LSB && secondaryFrameBuffer != nullptr) { + secondaryFrameBuffer[byteIndex] |= 1 << bitPosition; + } } } +// Writes a single pixel (always state=false / set bit) to an arbitrary buffer using the same +// orientation transform as drawPixel. Used by single-pass grayscale to write the MSB plane +// simultaneously with the LSB plane during a single renderFn call. +void GfxRenderer::drawPixelToBuffer(uint8_t* buf, const int x, const int y) const { + int phyX = 0, phyY = 0; + rotateCoordinates(orientation, x, y, &phyX, &phyY, panelWidth, panelHeight); + if (phyX < 0 || phyX >= panelWidth || phyY < 0 || phyY >= panelHeight) return; + const uint32_t byteIndex = static_cast(phyY) * panelWidthBytes + (phyX / 8); + buf[byteIndex] |= 1 << (7 - (phyX % 8)); +} + int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const { const auto fontIt = fontMap.find(fontId); if (fontIt == fontMap.end()) { @@ -321,19 +381,22 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const { if (fontCacheManager_ && fontCacheManager_->isScanning()) return; + // In GRAY2 modes the framebuffer convention is inverted vs BW: clearScreen(0x00) is background + // and drawPixel(false) marks active pixels. BW-convention callers pass state=true for "black". + const bool s = (renderMode == GRAY2_LSB || renderMode == GRAY2_MSB) ? !state : state; if (x1 == x2) { if (y2 < y1) { std::swap(y1, y2); } for (int y = y1; y <= y2; y++) { - drawPixel(x1, y, state); + drawPixel(x1, y, s); } } else if (y1 == y2) { if (x2 < x1) { std::swap(x1, x2); } for (int x = x1; x <= x2; x++) { - drawPixel(x, y1, state); + drawPixel(x, y1, s); } } else { // Bresenham's line algorithm — integer arithmetic only @@ -346,7 +409,7 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con int err = dx - dy; while (true) { - drawPixel(x1, y1, state); + drawPixel(x1, y1, s); if (x1 == x2 && y1 == y2) break; int e2 = 2 * err; if (e2 > -dy) { @@ -398,6 +461,8 @@ void GfxRenderer::drawArc(const int maxRadius, const int cx, const int cy, const const int outerRadiusSq = outerRadius * outerRadius; const int innerRadiusSq = innerRadius * innerRadius; + // Do NOT pre-invert state for GRAY2 here: fillRect→drawLine already handles the GRAY2 + // inversion. A pre-inversion here would double-invert (cancel out), rendering the wrong color. int xOuter = outerRadius; int xInner = innerRadius; @@ -500,22 +565,28 @@ void GfxRenderer::drawPixelDither(const int x, const int y) const template <> void GfxRenderer::drawPixelDither(const int x, const int y) const { - drawPixel(x, y, true); + const bool gray2 = renderMode == GRAY2_LSB || renderMode == GRAY2_MSB; + drawPixel(x, y, !gray2); } template <> void GfxRenderer::drawPixelDither(const int x, const int y) const { - drawPixel(x, y, false); + const bool gray2 = renderMode == GRAY2_LSB || renderMode == GRAY2_MSB; + drawPixel(x, y, gray2); } template <> void GfxRenderer::drawPixelDither(const int x, const int y) const { - drawPixel(x, y, x % 2 == 0 && y % 2 == 0); + const bool pix = x % 2 == 0 && y % 2 == 0; + const bool gray2 = renderMode == GRAY2_LSB || renderMode == GRAY2_MSB; + drawPixel(x, y, gray2 ? !pix : pix); } template <> void GfxRenderer::drawPixelDither(const int x, const int y) const { - drawPixel(x, y, (x + y) % 2 == 0); // TODO: maybe find a better pattern? + const bool pix = (x + y) % 2 == 0; // TODO: maybe find a better pattern? + const bool gray2 = renderMode == GRAY2_LSB || renderMode == GRAY2_MSB; + drawPixel(x, y, gray2 ? !pix : pix); } void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const { @@ -764,12 +835,88 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con return; } + // --- Pre-compute everything that is constant for the entire render --- + + // Orientation: collapse into 6 integer coefficients (same approach as DirectPixelWriter). + // phyX = phyXBase + screenY*phyXStepY + screenX*phyXStepX + // phyY = phyYBase + screenY*phyYStepY + screenX*phyYStepX + int phyXBase, phyYBase, phyXStepX, phyYStepX, phyXStepY, phyYStepY; + switch (orientation) { + case Portrait: + phyXBase = 0; + phyYBase = panelHeight - 1; + phyXStepX = 0; + phyYStepX = -1; + phyXStepY = 1; + phyYStepY = 0; + break; + case LandscapeClockwise: + phyXBase = panelWidth - 1; + phyYBase = panelHeight - 1; + phyXStepX = -1; + phyYStepX = 0; + phyXStepY = 0; + phyYStepY = -1; + break; + case PortraitInverted: + phyXBase = panelWidth - 1; + phyYBase = 0; + phyXStepX = 0; + phyYStepX = 1; + phyXStepY = -1; + phyYStepY = 0; + break; + case LandscapeCounterClockwise: + default: + phyXBase = 0; + phyYBase = 0; + phyXStepX = 1; + phyYStepX = 0; + phyXStepY = 0; + phyYStepY = 1; + break; + } + + // Per-val write masks (val is 2-bit: 0=black,1=darkGrey,2=lightGrey,3=white). + // Bit i of the mask is set when val==i should trigger a write. + // Evaluated once here; zero branch overhead inside the pixel loop. + uint8_t writeFbMask = 0; // which val values write to frameBuffer + uint8_t writeFb2Mask = 0; // which val values write to secondaryFrameBuffer (GRAY2_LSB single-pass only) + bool fbClearBit = false; // true = clear bit (BW black); false = set bit (all gray modes) + uint8_t* const fb2 = secondaryFrameBuffer; + + switch (renderMode) { + case BW: + writeFbMask = 0x3; + fbClearBit = true; + break; // val 0,1 (black+darkGrey) + case GRAYSCALE_MSB: + writeFbMask = 0x6; + break; // val 1,2 + case GRAYSCALE_LSB: + writeFbMask = 0x2; + break; // val 1 + case GRAY2_LSB: + writeFbMask = 0x5; // val 0,2 (LSB plane) + if (fb2) writeFb2Mask = 0x3; + break; // val 0,1 (MSB plane) + case GRAY2_MSB: + writeFbMask = 0x3; + break; // val 0,1 + default: + break; + } + + // Pre-computed for the unscaled incremental inner loop: stride through the physical Y axis per logical X step. + const int32_t byteIdxYStep = static_cast(phyYStepX) * static_cast(panelWidthBytes); + + // --- Outer row loop --- for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) { // The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative). // Screen's (0, 0) is the top-left corner. int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); if (isScaled) { - screenY = std::floor(screenY * scale); + screenY = static_cast(std::floor(screenY * scale)); } screenY += y; // the offset should not be scaled if (screenY >= getScreenHeight()) { @@ -792,27 +939,63 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con continue; } - for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { - int screenX = bmpX - cropPixX; - if (isScaled) { - screenX = std::floor(screenX * scale); - } - screenX += x; // the offset should not be scaled - if (screenX >= getScreenWidth()) { - break; - } - if (screenX < 0) { - continue; - } - - const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; + // Pre-compute the Y-dependent portion of the physical coordinate transform once per row. + const int rowPhyXBase = phyXBase + screenY * phyXStepY; + const int rowPhyYBase = phyYBase + screenY * phyYStepY; - if (renderMode == BW && val < 3) { - drawPixel(screenX, screenY); - } else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) { - drawPixel(screenX, screenY, false); - } else if (renderMode == GRAYSCALE_LSB && val == 1) { - drawPixel(screenX, screenY, false); + if (isScaled) { + // Scaled path: float accumulator replaces per-column multiply. + // Integer coordinate multiplies remain but are rare (scaled images only). + float screenXF = static_cast(x); + for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++, screenXF += scale) { + const int screenX = static_cast(screenXF); + if (screenX >= getScreenWidth()) break; + if (screenX < 0) continue; + + const uint8_t val = (outputRow[bmpX >> 2] >> (6 - (bmpX & 3) * 2)) & 0x3; + const bool doFb = (writeFbMask >> val) & 1; + const bool doFb2 = (writeFb2Mask >> val) & 1; + if (!doFb && !doFb2) continue; + + const int phyX = rowPhyXBase + screenX * phyXStepX; + const int phyY = rowPhyYBase + screenX * phyYStepX; + const uint32_t byteIdx = static_cast(phyY) * panelWidthBytes + (phyX >> 3); + const uint8_t bitMask = 1 << (7 - (phyX & 7)); + if (doFb) { + if (fbClearBit) + frameBuffer[byteIdx] &= ~bitMask; + else + frameBuffer[byteIdx] |= bitMask; + } + if (doFb2) fb2[byteIdx] |= bitMask; + } + } else { + // Unscaled path: fully incremental — zero multiplies, zero float in the pixel loop. + // curPhyX and curByteIdxY start at screenX=x (when bmpX=cropPixX) and advance by + // phyXStepX / byteIdxYStep per column. The for-increment fires on every iteration + // including continue, so running state stays in sync with bmpX even for skipped pixels. + int curPhyX = rowPhyXBase + x * phyXStepX; + int32_t curByteIdx = static_cast(rowPhyYBase + x * phyYStepX) * static_cast(panelWidthBytes); + for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; + bmpX++, curPhyX += phyXStepX, curByteIdx += byteIdxYStep) { + const int screenX = bmpX - cropPixX + x; + if (screenX >= getScreenWidth()) break; + if (screenX < 0) continue; + + const uint8_t val = (outputRow[bmpX >> 2] >> (6 - (bmpX & 3) * 2)) & 0x3; + const bool doFb = (writeFbMask >> val) & 1; + const bool doFb2 = (writeFb2Mask >> val) & 1; + if (!doFb && !doFb2) continue; + + const uint32_t byteIdx = static_cast(curByteIdx) + static_cast(curPhyX >> 3); + const uint8_t bitMask = 1 << (7 - (curPhyX & 7)); + if (doFb) { + if (fbClearBit) + frameBuffer[byteIdx] &= ~bitMask; + else + frameBuffer[byteIdx] |= bitMask; + } + if (doFb2) fb2[byteIdx] |= bitMask; } } } @@ -877,10 +1060,13 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, // Get 2-bit value (result of readNextRow 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) + // For 1-bit source: val < 3 = black, val == 3 = white if (val < 3) { - drawPixel(screenX, screenY, true); + if (renderMode == GRAY2_LSB || renderMode == GRAY2_MSB) { + drawPixel(screenX, screenY, false); + } else { + drawPixel(screenX, screenY, true); + } } // White pixels (val == 3) are not drawn (leave background) } @@ -958,6 +1144,11 @@ void GfxRenderer::clearScreen(const uint8_t color) const { display.clearScreen(color); } +void GfxRenderer::setScreenshotHook(ScreenshotHook hook, void* ctx) { + screenshotHook = hook; + screenshotHookCtx = ctx; +} + void GfxRenderer::invertScreen() const { for (uint32_t i = 0; i < frameBufferSize; i++) { frameBuffer[i] = ~frameBuffer[i]; @@ -967,7 +1158,28 @@ void GfxRenderer::invertScreen() const { void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const { auto elapsed = millis() - start_ms; LOG_DBG("GFX", "Time = %lu ms from clearScreen to displayBuffer", elapsed); - display.displayBuffer(refreshMode, fadingFix); + // After a factory LUT render, RED RAM still contains the grayscale MSB plane. + // Promote the first normal FAST refresh to HALF so both RAM banks are rebased + // before differential updates resume. + const bool afterFactoryLut = displayState == DisplayState::FactoryLut; + const auto effectiveRefreshMode = + afterFactoryLut && refreshMode == HalDisplay::FAST_REFRESH ? HalDisplay::HALF_REFRESH : refreshMode; + display.displayBuffer(effectiveRefreshMode, fadingFix); + displayState = DisplayState::BW; +} + +void GfxRenderer::displayBufferPrecondition(uint8_t color) const { + display.displayBufferPrecondition(color); + displayState = DisplayState::BW; +} + +void GfxRenderer::displayGrayBuffer(const unsigned char* lut, const bool factoryMode) const { + display.displayGrayBuffer(fadingFix, lut, factoryMode); + if (factoryMode) { + displayState = DisplayState::FactoryLut; + } else { + displayState = DisplayState::BW; + } } std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, @@ -1274,7 +1486,252 @@ void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuff void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(frameBuffer); } -void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(fadingFix); } +void GfxRenderer::renderGrayscale(GrayscaleDriveMode mode, void (*renderFn)(const GfxRenderer&, const void*), + const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*), + const void* preFlashCtx) { + const auto spec = resolveGrayscaleDrive(mode); + + if (spec.factoryMode) { + clearScreen(); + if (preFlashOverlayFn) preFlashOverlayFn(*this, preFlashCtx); + displayBuffer(HalDisplay::HALF_REFRESH); + } + + g_differentialQuantize = spec.differentialQuantize; + + clearScreen(0x00); + setRenderMode(spec.lsbMode); + renderFn(*this, ctx); + + uint8_t* lsbCopy = nullptr; + if (screenshotHook && spec.factoryMode) { + lsbCopy = static_cast(malloc(frameBufferSize)); + if (lsbCopy) { + memcpy(lsbCopy, frameBuffer, frameBufferSize); + } else { + // Allocation failed — disarm the one-shot hook so it doesn't fire on a future render. + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + } + } + + // Match stock SPI order for factory mode: setCustomLUT + Border BEFORE the + // RAM writes. Differential path skips Setup (handled by displayGrayBuffer at + // the end). See docs/v559-disassembly-findings.md Difference #4. + if (spec.factoryMode) { + display.displayGrayBufferFactorySetup(spec.lut); + } + copyGrayscaleLsbBuffers(); + + clearScreen(0x00); + setRenderMode(spec.msbMode); + renderFn(*this, ctx); + copyGrayscaleMsbBuffers(); + + // Fire hook: LSB = lsbCopy, MSB = frameBuffer (still holds second-pass data). + if (screenshotHook && spec.factoryMode && lsbCopy) { + screenshotHook(lsbCopy, frameBuffer, panelWidth, panelHeight, screenshotHookCtx); + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + } + if (lsbCopy) { + free(lsbCopy); + lsbCopy = nullptr; + } + + g_differentialQuantize = false; + + // Factory mode: differential path keeps the original combined call. Factory + // path uses the split SDK API so the LUT load happens BEFORE RAM writes — + // matches stock V5.5.9 SPI order. Same pattern as renderGrayscaleSinglePass. + if (spec.factoryMode) { + display.displayGrayBufferFactoryActivate(); + displayState = DisplayState::FactoryLut; + } else { + displayGrayBuffer(spec.lut, spec.factoryMode); + } + // Suppress the SDK's automatic grayscaleRevert on the next BW page turn. + // Caller is responsible for cleanup: restoreBwBuffer rebases RED RAM, and + // displayBuffer promotes the first post-factory FAST refresh to HALF. + display.clearGrayscaleModeFlag(); + setRenderMode(BW); +} + +void GfxRenderer::renderGrayscaleSinglePass(GrayscaleDriveMode mode, void (*renderFn)(const GfxRenderer&, const void*), + const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*), + const void* preFlashCtx, const HalDisplay::RefreshMode preFlashRefreshMode, + const uint8_t preFlashPasses) { + const auto spec = resolveGrayscaleDrive(mode); + + if (spec.factoryMode && preFlashPasses > 0) { + for (uint8_t pass = 0; pass < preFlashPasses; pass++) { + clearScreen(); + if (pass == 0 && preFlashOverlayFn) preFlashOverlayFn(*this, preFlashCtx); + displayBuffer(preFlashRefreshMode); + } + } + + g_differentialQuantize = spec.differentialQuantize; + + auto* secBuf = static_cast(malloc(frameBufferSize)); + if (!secBuf) { + LOG_ERR("GFX", "renderGrayscaleSinglePass: malloc failed (%lu bytes), falling back to two-pass", + static_cast(frameBufferSize)); + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + clearScreen(0x00); + setRenderMode(spec.lsbMode); + renderFn(*this, ctx); + copyGrayscaleLsbBuffers(); + clearScreen(0x00); + setRenderMode(spec.msbMode); + renderFn(*this, ctx); + copyGrayscaleMsbBuffers(); + g_differentialQuantize = false; + displayGrayBuffer(spec.lut, spec.factoryMode); + // See note in renderGrayscale(). + display.clearGrayscaleModeFlag(); + setRenderMode(BW); + return; + } + memset(secBuf, 0x00, frameBufferSize); + secondaryFrameBuffer = secBuf; + + // Single pass: renderFn writes LSB plane to frameBuffer and MSB plane to secondaryFrameBuffer. + clearScreen(0x00); + setRenderMode(spec.lsbMode); + renderFn(*this, ctx); + + // One-shot screenshot hook: fired while both planes are still in software, before either is + // pushed to the controller. frameBuffer = LSB plane, secBuf = MSB plane. + if (screenshotHook && spec.factoryMode) { + screenshotHook(frameBuffer, secBuf, panelWidth, panelHeight, screenshotHookCtx); + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + } + + // EXPERIMENT: match stock V5.5.9 SPI order: LUT load → RAM writes → activate. + // For factory mode, use the split SDK API so RAM data is written AFTER setCustomLUT + // and Border Waveform, BEFORE the MASTER_ACTIVATION. See docs/v559-disassembly-findings.md. + if (spec.factoryMode) { + display.displayGrayBufferFactorySetup(spec.lut); + copyGrayscaleLsbBuffers(); + memcpy(frameBuffer, secBuf, frameBufferSize); + copyGrayscaleMsbBuffers(); + free(secBuf); + secondaryFrameBuffer = nullptr; + g_differentialQuantize = false; + display.displayGrayBufferFactoryActivate(); + displayState = DisplayState::FactoryLut; + display.clearGrayscaleModeFlag(); + setRenderMode(BW); + return; + } + + // Differential path: original order (RAM writes then combined displayGrayBuffer). + copyGrayscaleLsbBuffers(); + memcpy(frameBuffer, secBuf, frameBufferSize); + copyGrayscaleMsbBuffers(); + + free(secBuf); + secondaryFrameBuffer = nullptr; + + g_differentialQuantize = false; + displayGrayBuffer(spec.lut, spec.factoryMode); + display.clearGrayscaleModeFlag(); + setRenderMode(BW); +} + +void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, const uint16_t pageWidth, + const uint16_t pageHeight, RenderHook overlayFn, const void* overlayCtx, + GrayscaleDriveMode mode, const bool preFlash, + const HalDisplay::RefreshMode preFlashRefreshMode, const uint8_t preFlashPasses) { + const size_t colBytes = (pageHeight + 7) / 8; + const uint16_t fbStride = panelWidthBytes; + + // Bounds check: each column c writes colBytes bytes at frameBuffer[c * fbStride]. + // Requires pageWidth <= panelHeight and colBytes <= panelWidthBytes. + if (pageWidth > static_cast(panelHeight) || colBytes > panelWidthBytes) { + LOG_ERR("GFX", "displayXtchPlanes: page %ux%u overflows framebuffer (%ux%u)", pageWidth, pageHeight, panelHeight, + panelWidth); + if (screenshotHook) { + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + } + return; + } + + if (preFlash && preFlashPasses > 0) { + for (uint8_t pass = 0; pass < preFlashPasses; pass++) { + clearScreen(); + displayBuffer(preFlashRefreshMode); + } + } + + // Match stock V5.5.9 SPI order: setCustomLUT + Border BEFORE RAM writes. + // Resolve drive spec early so we can use Setup/Activate. See + // docs/v559-disassembly-findings.md Difference #4. + const auto spec = resolveGrayscaleDrive(mode); + display.displayGrayBufferFactorySetup(spec.lut); + + // Pass 1: plane1 (MSB) → BW RAM via copyGrayscaleLsbBuffers. + clearScreen(0x00); + for (uint16_t c = 0; c < pageWidth; c++) { + const uint8_t* srcCol = plane1 + static_cast(c) * colBytes; + uint8_t* dstRow = frameBuffer + static_cast(c) * fbStride; + for (uint16_t b = 0; b < colBytes; b++) { + dstRow[b] = srcCol[b]; + } + } + setRenderMode(GRAY2_LSB); + if (overlayFn) overlayFn(*this, overlayCtx); + + copyGrayscaleLsbBuffers(); + + // Pass 2: plane2 (LSB) → RED RAM via copyGrayscaleMsbBuffers. + clearScreen(0x00); + for (uint16_t c = 0; c < pageWidth; c++) { + const uint8_t* srcCol = plane2 + static_cast(c) * colBytes; + uint8_t* dstRow = frameBuffer + static_cast(c) * fbStride; + for (uint16_t b = 0; b < colBytes; b++) { + dstRow[b] = srcCol[b]; + } + } + setRenderMode(GRAY2_MSB); + if (overlayFn) overlayFn(*this, overlayCtx); + copyGrayscaleMsbBuffers(); + + // Fire hook: plane1 input IS already in framebuffer format (colBytes == fbStride for portrait + // pages), so pass it directly — no extra malloc needed. plane2 data is now in frameBuffer. + if (screenshotHook) { + screenshotHook(plane1, frameBuffer, panelWidth, panelHeight, screenshotHookCtx); + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + } + + display.displayGrayBufferFactoryActivate(); + displayState = DisplayState::FactoryLut; + setRenderMode(BW); +} + +void GfxRenderer::displayXtcBwPage(const uint8_t* pageBuffer, const uint16_t pageWidth, const uint16_t pageHeight, + const HalDisplay::RefreshMode refreshMode, RenderHook overlayFn, + const void* overlayCtx) { + const size_t srcRowBytes = (pageWidth + 7) / 8; + + // 1-bit content has no AA — render as plain BW and use the standard differential fast-refresh + // LUT (same as menus/EPUB). No factory LUT needed; avoids all GRAY2 convention complexity. + clearScreen(); + for (uint16_t y = 0; y < pageHeight; y++) { + for (uint16_t x = 0; x < pageWidth; x++) { + if (!((pageBuffer[y * srcRowBytes + x / 8] >> (7 - (x % 8))) & 1)) { + drawPixel(x, y, true); + } + } + } + if (overlayFn) overlayFn(*this, overlayCtx); + displayBuffer(refreshMode); +} void GfxRenderer::freeBwBufferChunks() { for (auto& bwBufferChunk : bwBufferChunks) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index fd9b96dcc1..c455deb440 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -19,7 +19,13 @@ enum Color : uint8_t { Clear = 0x00, White = 0x01, LightGray = 0x05, DarkGray = class GfxRenderer { public: - enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; + enum RenderMode { + BW, // 1-bit black/white + GRAYSCALE_LSB, // Differential gray: mark pixels for LSB plane (clearScreen(0x00) + drawPixel(false)) + GRAYSCALE_MSB, // Differential gray: mark pixels for MSB plane (clearScreen(0x00) + drawPixel(false)) + GRAY2_LSB, // Factory absolute gray: encode BW RAM = bit0 (clearScreen(0x00) + drawPixel(false)) + GRAY2_MSB, // Factory absolute gray: encode RED RAM = bit1 (clearScreen(0x00) + drawPixel(false)) + }; // Logical screen orientation from the perspective of callers enum Orientation { @@ -29,6 +35,29 @@ class GfxRenderer { LandscapeCounterClockwise // 800x480 logical coordinates, native panel orientation }; + // Selects LUT and pixel-plane encoding for renderGrayscale() / renderGrayscaleSinglePass() / + // displayXtchPlanes(). Pre-flash policy is per-call, not part of the drive mode. + enum class GrayscaleDriveMode { + FactoryFast, // Factory absolute 2-bit (lut_factory_fast) + FactoryQuality, // Factory absolute 2-bit (lut_factory_quality) + Differential, // Differential 2-bit overlay (no LUT); requires prior BW state + }; + + // Display state — tracks whether the physical display was last updated via a factory LUT render. + // BW: frameBuffer mirrors the display (menus, EPUB reader). + // FactoryLut: display holds a grayscale image; frameBuffer has been reset to white by + // cleanupGrayscaleWithFrameBuffer() and no longer represents what is visually shown. + enum class DisplayState { BW, FactoryLut }; + + // One-shot hook fired in renderGrayscaleSinglePass after renderFn() writes both planes + // but before they are pushed to the controller. At that moment: + // lsbPlane = frameBuffer (LSB / BW RAM plane) + // msbPlane = secondaryFrameBuffer (MSB / RED RAM plane) + // Hook is cleared automatically after firing. + using ScreenshotHook = void (*)(const uint8_t* lsbPlane, const uint8_t* msbPlane, int physWidth, int physHeight, + void* ctx); + using RenderHook = void (*)(const GfxRenderer&, const void*); + private: static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory @@ -37,12 +66,16 @@ class GfxRenderer { Orientation orientation; bool fadingFix; uint8_t* frameBuffer = nullptr; + uint8_t* secondaryFrameBuffer = nullptr; // MSB plane buffer for single-pass grayscale decode uint16_t panelWidth = HalDisplay::DISPLAY_WIDTH; uint16_t panelHeight = HalDisplay::DISPLAY_HEIGHT; uint16_t panelWidthBytes = HalDisplay::DISPLAY_WIDTH_BYTES; uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE; std::vector bwBufferChunks; std::map fontMap; + mutable DisplayState displayState = DisplayState::BW; + ScreenshotHook screenshotHook = nullptr; + void* screenshotHookCtx = nullptr; // Mutable because ensureSdCardFontReady() is const (called from layout code // that holds a const GfxRenderer&) but triggers SD card reads and heap // allocation inside the SdCardFont objects. Same pragmatic compromise as @@ -61,6 +94,7 @@ class GfxRenderer { void drawPixelDither(int x, int y) const; template void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const; + void drawPixelToBuffer(uint8_t* buf, int x, int y) const; public: explicit GfxRenderer(HalDisplay& halDisplay) @@ -106,6 +140,10 @@ class GfxRenderer { int getScreenWidth() const; int getScreenHeight() const; void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; + // Stock-V5.5.9 byte-match precondition flash (black or white). Fires CTRL2=0xF7 + // (full power-cycle, rails off after) and skips SINGLE_BUFFER_MODE post-RED-sync. + // See docs/v559-disassembly-findings.md. + void displayBufferPrecondition(uint8_t color) const; // EXPERIMENTAL: Windowed update - display only a rectangular region // void displayWindow(int x, int y, int width, int height) const; void invertScreen() const; @@ -164,21 +202,61 @@ class GfxRenderer { EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; int getTextHeight(int fontId) const; + DisplayState getDisplayState() const { return displayState; } + void setScreenshotHook(ScreenshotHook hook, void* ctx); + // Grayscale functions void setRenderMode(const RenderMode mode) { this->renderMode = mode; } RenderMode getRenderMode() const { return renderMode; } void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; - void displayGrayBuffer() const; + void displayGrayBuffer(const unsigned char* lut = nullptr, bool factoryMode = false) const; bool storeBwBuffer(); // Returns true if buffer was stored successfully void restoreBwBuffer(); // Restore and free the stored buffer void cleanupGrayscaleWithFrameBuffer() const; + // Two-pass grayscale render. renderFn is called twice: once with the LSB render mode set + // (writes BW RAM plane), then with the MSB mode set (writes RED RAM plane). The method + // handles pre-flash (FactoryFast only), clearScreen, setRenderMode, buffer copies, + // displayGrayBuffer, and resets renderMode to BW on completion. + // storeBwBuffer / restoreBwBuffer remain the caller's responsibility. + // preFlashOverlayFn (optional): called after clearScreen() but before the pre-flash displayBuffer(), + // allowing callers to draw a loading indicator that appears during the pre-flash without an extra refresh. + void renderGrayscale(GrayscaleDriveMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, + void (*preFlashOverlayFn)(const GfxRenderer&, const void*) = nullptr, + const void* preFlashCtx = nullptr); + // Single-pass variant: calls renderFn once in GRAY2_LSB mode while simultaneously writing + // the MSB plane to a secondary buffer. Cuts SD card reads from 2 to 1 for file-backed renders. + // Falls back to two-pass on secondary buffer allocation failure. Factory modes pre-flash to + // white with HALF_REFRESH by default. preFlashPasses=0 skips the internal pre-flash so + // callers can run a custom conditioning sequence first. + void renderGrayscaleSinglePass(GrayscaleDriveMode mode, void (*renderFn)(const GfxRenderer&, const void*), + const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*) = nullptr, + const void* preFlashCtx = nullptr, + HalDisplay::RefreshMode preFlashRefreshMode = HalDisplay::HALF_REFRESH, + uint8_t preFlashPasses = 1); + + // Direct 2-bit XTCH plane blit using factory LUT. Caller supplies the two decoded bit planes + // (plane1 = BW RAM / LSB, plane2 = RED RAM / MSB) in column-major order matching XTCH encoding. + // Handles optional pre-flash, both RAM writes, factory LUT fire, and BW controller sync internally. + // mode selects the factory LUT: FactoryFast (default) or FactoryQuality; Differential is invalid here. + void displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, uint16_t pageWidth, uint16_t pageHeight, + RenderHook overlayFn = nullptr, const void* overlayCtx = nullptr, + GrayscaleDriveMode mode = GrayscaleDriveMode::FactoryFast, bool preFlash = false, + HalDisplay::RefreshMode preFlashRefreshMode = HalDisplay::HALF_REFRESH, + uint8_t preFlashPasses = 1); + + // 1-bit XTC page via the same grayscale LUT pipeline. Row-major pageBuffer (XTC: 0=black, 1=white). + // BW and RED RAM receive identical data since there are no intermediate gray levels. + void displayXtcBwPage(const uint8_t* pageBuffer, uint16_t pageWidth, uint16_t pageHeight, + HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH, RenderHook overlayFn = nullptr, + const void* overlayCtx = nullptr); // Font helpers const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const; // Low level functions uint8_t* getFrameBuffer() const; + uint8_t* getSecondaryFrameBuffer() const { return secondaryFrameBuffer; } size_t getBufferSize() const; uint16_t getDisplayWidth() const { return panelWidth; } uint16_t getDisplayHeight() const { return panelHeight; } diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index ab9917f101..edd5b08cc6 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -230,6 +230,9 @@ STR_PREVIEW: "Preview" STR_TITLE: "Title" STR_BATTERY: "Battery" STR_XTC_STATUS_BAR: "XTC Status Bar" +STR_XTC_RENDER_QUALITY: "2-bit XTC" +STR_SPEED: "Speed" +STR_QUALITY: "Quality" STR_BOTTOM: "Bottom" STR_TOP: "Top" STR_UI_THEME: "UI Theme" diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 0dd7872753..44bd504673 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -245,14 +245,13 @@ static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY) if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow(); } else { for (int x = 0; x < ctx->outWidth; x++) { - const uint8_t gray = adjustPixel(srcRow[x]); uint8_t twoBit; if (ctx->atkinsonDitherer) { - twoBit = ctx->atkinsonDitherer->processPixel(gray, x); + twoBit = ctx->atkinsonDitherer->processPixel(srcRow[x], x); } else if (ctx->fsDitherer) { - twoBit = ctx->fsDitherer->processPixel(gray, x); + twoBit = ctx->fsDitherer->processPixel(srcRow[x], x); } else { - twoBit = quantize(gray, x, outY); + twoBit = quantize(srcRow[x], x, outY); } ctx->bmpRow[(x * 2) / 8] |= (twoBit << (6 - ((x * 2) % 8))); } @@ -284,7 +283,7 @@ static void flushScaledRow(BmpConvertCtx* ctx) { if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow(); } else { for (int x = 0; x < ctx->outWidth; x++) { - const uint8_t gray = adjustPixel((ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0); + const uint8_t gray = (ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0; uint8_t twoBit; if (ctx->atkinsonDitherer) { twoBit = ctx->atkinsonDitherer->processPixel(gray, x); diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 5f0388d28f..1313bfe0b8 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -11,6 +11,16 @@ #include #include +static inline void computeSrcRange(uint32_t dstCoord, uint32_t scaleInv, uint32_t maxSrc, uint32_t& srcStart, + uint32_t& srcEnd) { + srcStart = (static_cast(dstCoord) * scaleInv) >> 16; + srcEnd = (static_cast(dstCoord + 1) * scaleInv) >> 16; + if (srcStart >= maxSrc) srcStart = maxSrc - 1; + if (srcEnd > maxSrc) srcEnd = maxSrc; + if (srcEnd <= srcStart) srcEnd = srcStart + 1; + if (srcEnd > maxSrc) srcEnd = maxSrc; +} + bool Xtc::load() { LOG_DBG("XTC", "Loading XTC: %s", filepath.c_str()); @@ -142,22 +152,150 @@ bool Xtc::generateCoverBmp() const { // 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; + // --- 2-bit XTCH/XTH: two-pass plane loading to stay within heap limits --- + // Each plane is ~48KB (fits MaxAlloc); both planes together (~96KB) do not. if (bitDepth == 2) { - bitmapSize = ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) * 2; - } else { - bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; + const size_t planeSize = (static_cast(pageInfo.width) * pageInfo.height + 7) / 8; + const size_t colBytes = (pageInfo.height + 7) / 8; + const uint32_t rowSize2 = ((static_cast(pageInfo.width) * 2 + 31) / 32) * 4; + const std::string tempPath = cachePath + "/cover_tmp.bmp"; + + // Pass 1: load plane1, write raw 2-bit rows to temp file (bit2=0, pixel = bit1<<1) + { + uint8_t* plane1 = static_cast(malloc(planeSize)); + if (!plane1) { + LOG_ERR("XTC", "Failed to alloc plane1 (%lu bytes)", planeSize); + return false; + } + if (const_cast(parser.get())->loadPageMsb(0, plane1, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane1 for cover"); + free(plane1); + return false; + } + FsFile tempFile; + if (!Storage.openFileForWrite("XTC", tempPath, tempFile)) { + LOG_ERR("XTC", "Failed to open temp cover file"); + free(plane1); + return false; + } + uint8_t rowBuf[256]; + for (uint16_t y = 0; y < pageInfo.height; y++) { + memset(rowBuf, 0, rowSize2); + for (uint16_t x = 0; x < pageInfo.width; x++) { + const size_t bo = (pageInfo.width - 1 - x) * colBytes + y / 8; + const uint8_t bit1 = (plane1[bo] >> (7 - (y % 8))) & 1; + rowBuf[x / 4] |= static_cast((bit1 << 1) << (6 - (x % 4) * 2)); + } + if (tempFile.write(rowBuf, rowSize2) != rowSize2) { + LOG_ERR("XTC", "Failed to write temp cover row %u", y); + tempFile.close(); + free(plane1); + Storage.remove(tempPath.c_str()); + return false; + } + } + tempFile.close(); + free(plane1); + } + + // Pass 2: load plane2, combine with pass1, write final 2-bit BMP + { + uint8_t* plane2 = static_cast(malloc(planeSize)); + if (!plane2) { + LOG_ERR("XTC", "Failed to alloc plane2 (%lu bytes)", planeSize); + Storage.remove(tempPath.c_str()); + return false; + } + if (const_cast(parser.get())->loadPageLsb(0, plane2, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane2 for cover"); + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } + FsFile tempFile, coverFile; + if (!Storage.openFileForRead("XTC", tempPath, tempFile)) { + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } + if (!Storage.openFileForWrite("XTC", getCoverBmpPath(), coverFile)) { + tempFile.close(); + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } + // Write 2-bit BMP header + const uint32_t imageSize2 = rowSize2 * pageInfo.height; + const uint32_t fileSize2 = 14 + 40 + 16 + imageSize2; + static constexpr uint8_t bmpHeader2[74] = { + 'B', 'M', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 2, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 4, 0, 0, + 0xFF, 0xFF, 0xFF, 0x00, 0xAA, 0xAA, 0xAA, 0x00, 0x55, 0x55, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00}; + uint8_t hdr[74]; + memcpy(hdr, bmpHeader2, 74); + memcpy(hdr + 2, &fileSize2, 4); + const uint32_t doff2 = 14 + 40 + 16; + memcpy(hdr + 10, &doff2, 4); + int32_t ww2 = pageInfo.width; + memcpy(hdr + 18, &ww2, 4); + int32_t hh2 = -static_cast(pageInfo.height); + memcpy(hdr + 22, &hh2, 4); + memcpy(hdr + 34, &imageSize2, 4); + const uint32_t ppm2 = 2835; + memcpy(hdr + 38, &ppm2, 4); + memcpy(hdr + 42, &ppm2, 4); + if (coverFile.write(hdr, 74) != 74) { + LOG_ERR("XTC", "Failed to write 2-bit BMP header"); + coverFile.close(); + tempFile.close(); + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } + // For each row: read pass1 row (bit1 only), OR in bit2, write to final + uint8_t rowBuf[256]; + for (uint16_t y = 0; y < pageInfo.height; y++) { + memset(rowBuf, 0, rowSize2); + if (tempFile.read(rowBuf, rowSize2) != rowSize2) { + LOG_ERR("XTC", "Failed to read temp cover row %u", y); + coverFile.close(); + tempFile.close(); + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } + for (uint16_t x = 0; x < pageInfo.width; x++) { + const size_t bo = (pageInfo.width - 1 - x) * colBytes + y / 8; + const uint8_t bit2 = (plane2[bo] >> (7 - (y % 8))) & 1; + rowBuf[x / 4] |= static_cast(bit2 << (6 - (x % 4) * 2)); + } + if (coverFile.write(rowBuf, rowSize2) != rowSize2) { + LOG_ERR("XTC", "Failed to write cover row %u", y); + coverFile.close(); + tempFile.close(); + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } + } + coverFile.close(); + tempFile.close(); + free(plane2); + Storage.remove(tempPath.c_str()); + } + LOG_DBG("XTC", "Generated 2-bit cover BMP: %s", getCoverBmpPath().c_str()); + return true; } + + // 1-bit (XTC/XTG) path + const size_t bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; uint8_t* pageBuffer = static_cast(malloc(bitmapSize)); if (!pageBuffer) { LOG_ERR("XTC", "Failed to allocate page buffer (%lu bytes)", bitmapSize); return false; } - // Load first page (cover) size_t bytesRead = const_cast(parser.get())->loadPage(0, pageBuffer, bitmapSize); if (bytesRead == 0) { LOG_ERR("XTC", "Failed to load cover page"); @@ -165,7 +303,6 @@ bool Xtc::generateCoverBmp() const { return false; } - // Create BMP file FsFile coverBmp; if (!Storage.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) { LOG_DBG("XTC", "Failed to create cover BMP file"); @@ -173,29 +310,23 @@ bool Xtc::generateCoverBmp() const { return false; } - // Write 1-bit BMP header (top-down row order) BmpHeader bmpHeader; createBmpHeader(&bmpHeader, pageInfo.width, pageInfo.height, BmpRowOrder::TopDown); coverBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4; + const size_t srcRowSize = (pageInfo.width + 7) / 8; // Write bitmap data // BMP requires 4-byte row alignment - const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size + const size_t dstRowSize = (pageInfo.width + 7) / 8; 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 + const uint8_t* plane1 = pageBuffer; + const uint8_t* plane2 = pageBuffer + planeSize; + const size_t colBytes = (pageInfo.height + 7) / 8; - // Allocate a row buffer for 1-bit output uint8_t* rowBuffer = static_cast(malloc(dstRowSize)); if (!rowBuffer) { free(pageBuffer); @@ -203,32 +334,27 @@ bool Xtc::generateCoverBmp() const { } for (uint16_t y = 0; y < pageInfo.height; y++) { - memset(rowBuffer, 0xFF, dstRowSize); // Start with all white + memset(rowBuffer, 0xFF, dstRowSize); 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 bitInByte = 7 - (y % 8); 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) { @@ -238,14 +364,9 @@ bool Xtc::generateCoverBmp() const { 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) { @@ -328,20 +449,141 @@ bool Xtc::generateThumbBmp(int height) const { LOG_DBG("XTC", "Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)", pageInfo.width, pageInfo.height, thumbWidth, thumbHeight, scale); - // Allocate buffer for page data - size_t bitmapSize; + // For 2-bit (XTCH): strip-based sequential plane loading → area-averaged Atkinson dithered 1-bit BMP. + // Loads each plane separately (~48KB) in strips to stay within memory constraints after reader sessions. + // Uses area-averaging (same formula as the 1-bit path) for accurate luminance computation. if (bitDepth == 2) { - bitmapSize = ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) * 2; - } else { - bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; + const size_t planeSize = (static_cast(pageInfo.width) * pageInfo.height + 7) / 8; + const size_t colBytes = (pageInfo.height + 7) / 8; + const uint32_t scaleInv_fp2 = static_cast(65536.0f / scale); + + uint8_t* planeBuffer = static_cast(malloc(planeSize)); + if (!planeBuffer) { + LOG_ERR("XTC", "Failed to alloc plane buffer (%lu bytes)", static_cast(planeSize)); + return false; + } + + const size_t stripBudget = (static_cast(thumbWidth) * thumbHeight + 7) / 8; + const int stripRows = std::min(static_cast(thumbHeight), static_cast(stripBudget / thumbWidth)); + + uint8_t* darkCount1Buf = static_cast(malloc(static_cast(stripRows) * thumbWidth)); + if (!darkCount1Buf) { + LOG_ERR("XTC", "Failed to alloc darkCount1 buffer (%lu bytes)", + static_cast(static_cast(stripRows) * thumbWidth)); + free(planeBuffer); + return false; + } + + FsFile thumbBmp; + if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { + free(darkCount1Buf); + free(planeBuffer); + return false; + } + + BmpHeader bmpHeader; + createBmpHeader(&bmpHeader, thumbWidth, thumbHeight, BmpRowOrder::TopDown); + thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); + + const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; + uint8_t* rowBuf = static_cast(malloc(rowSize)); + if (!rowBuf) { + free(darkCount1Buf); + free(planeBuffer); + thumbBmp.close(); + Storage.remove(getThumbBmpPath(height).c_str()); + return false; + } + + Atkinson1BitDitherer ditherer(thumbWidth); + + for (int stripStart = 0; stripStart < static_cast(thumbHeight); stripStart += stripRows) { + const int curRows = std::min(stripRows, static_cast(thumbHeight) - stripStart); + + // Pass 1: plane1 (bit1/MSB) — count dark pixels per output pixel + if (const_cast(parser.get())->loadPageMsb(0, planeBuffer, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane1 for thumb"); + free(rowBuf); + free(darkCount1Buf); + free(planeBuffer); + thumbBmp.close(); + Storage.remove(getThumbBmpPath(height).c_str()); + return false; + } + for (int dy = 0; dy < curRows; dy++) { + const uint16_t dstY = static_cast(stripStart + dy); + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + uint32_t srcYS, srcYE, srcXS, srcXE; + computeSrcRange(dstY, scaleInv_fp2, pageInfo.height, srcYS, srcYE); + computeSrcRange(dstX, scaleInv_fp2, pageInfo.width, srcXS, srcXE); + uint32_t darkCount = 0; + for (uint32_t sy = srcYS; sy < srcYE; sy++) + for (uint32_t sx = srcXS; sx < srcXE; sx++) { + const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; + if (bo < planeSize) { + if ((planeBuffer[bo] >> (7 - (sy % 8))) & 1) darkCount++; + } + } + darkCount1Buf[static_cast(dy) * thumbWidth + dstX] = static_cast(darkCount); + } + } + + // Pass 2: plane2 (bit2/LSB) — combine with darkCount1 → area-average → dither → write BMP rows + if (const_cast(parser.get())->loadPageLsb(0, planeBuffer, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane2 for thumb"); + free(rowBuf); + free(darkCount1Buf); + free(planeBuffer); + thumbBmp.close(); + Storage.remove(getThumbBmpPath(height).c_str()); + return false; + } + for (int dy = 0; dy < curRows; dy++) { + memset(rowBuf, 0xFF, rowSize); + const uint16_t dstY = static_cast(stripStart + dy); + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + uint32_t srcYS, srcYE, srcXS, srcXE; + computeSrcRange(dstY, scaleInv_fp2, pageInfo.height, srcYS, srcYE); + computeSrcRange(dstX, scaleInv_fp2, pageInfo.width, srcXS, srcXE); + uint32_t darkCount = 0, totalCount = 0; + for (uint32_t sy = srcYS; sy < srcYE; sy++) + for (uint32_t sx = srcXS; sx < srcXE; sx++) { + const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; + if (bo < planeSize) { + if ((planeBuffer[bo] >> (7 - (sy % 8))) & 1) darkCount++; + totalCount++; + } + } + const uint32_t dark1 = darkCount1Buf[static_cast(dy) * thumbWidth + dstX]; + const int lumSum = static_cast((totalCount - dark1) * 85 + (totalCount - darkCount) * 170); + const int avgLum = (totalCount > 0) ? (lumSum * 255 / totalCount) / 255 : 255; + const uint8_t bit = ditherer.processPixel(avgLum, dstX); + if (!bit) { + const size_t bi = dstX / 8; + if (bi < rowSize) rowBuf[bi] &= ~(1 << (7 - (dstX % 8))); + } + } + thumbBmp.write(rowBuf, rowSize); + ditherer.nextRow(); + } + } + + free(rowBuf); + free(darkCount1Buf); + free(planeBuffer); + thumbBmp.close(); + LOG_DBG("XTC", "Generated 1-bit thumb BMP with dithering (%dx%d): %s", thumbWidth, thumbHeight, + getThumbBmpPath(height).c_str()); + return true; } + + // 1-bit (XTC/XTG) path + const size_t bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; uint8_t* pageBuffer = static_cast(malloc(bitmapSize)); if (!pageBuffer) { LOG_ERR("XTC", "Failed to allocate page buffer (%lu bytes)", bitmapSize); return false; } - - // Load first page (cover) size_t bytesRead = const_cast(parser.get())->loadPage(0, pageBuffer, bitmapSize); if (bytesRead == 0) { LOG_ERR("XTC", "Failed to load cover page for thumb"); @@ -349,7 +591,6 @@ bool Xtc::generateThumbBmp(int height) const { return false; } - // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) FsFile thumbBmp; if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { LOG_DBG("XTC", "Failed to create thumb BMP file"); @@ -357,126 +598,56 @@ bool Xtc::generateThumbBmp(int height) const { return false; } - // Write 1-bit BMP header (top-down row order) BmpHeader bmpHeader; createBmpHeader(&bmpHeader, thumbWidth, thumbHeight, BmpRowOrder::TopDown); thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; - - // Allocate row buffer for 1-bit output uint8_t* rowBuffer = static_cast(malloc(rowSize)); if (!rowBuffer) { free(pageBuffer); return false; } - // Fixed-point scale factor (16.16) - uint32_t scaleInv_fp = static_cast(65536.0f / scale); - - // 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; + Atkinson1BitDitherer ditherer(thumbWidth); + const uint32_t scaleInv_fp = static_cast(65536.0f / scale); + const size_t srcRowBytes = (pageInfo.width + 7) / 8; 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(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; + memset(rowBuffer, 0xFF, rowSize); + uint32_t srcYStart, srcYEnd; + computeSrcRange(dstY, scaleInv_fp, pageInfo.height, srcYStart, srcYEnd); for (uint16_t dstX = 0; dstX < thumbWidth; 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; + uint32_t srcXStart, srcXEnd; + computeSrcRange(dstX, scaleInv_fp, pageInfo.width, srcXStart, srcXEnd); + uint32_t graySum = 0, 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; - } + const size_t byteIdx = srcY * srcRowBytes + srcX / 8; + if (byteIdx < bitmapSize) { + graySum += ((pageBuffer[byteIdx] >> (7 - (srcX % 8))) & 1) ? 255 : 0; + totalCount++; } - - 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 - } + const int avgGray = (totalCount > 0) ? static_cast(graySum * 255 / totalCount) / 255 : 255; + const uint8_t bit = ditherer.processPixel(avgGray, dstX); + if (!bit) { + const size_t bi = dstX / 8; + if (bi < rowSize) rowBuffer[bi] &= ~(1 << (7 - (dstX % 8))); } } - - // Write row (already padded to 4-byte boundary by rowSize) thumbBmp.write(rowBuffer, rowSize); + ditherer.nextRow(); } free(rowBuffer); free(pageBuffer); - - LOG_DBG("XTC", "Generated thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str()); + LOG_DBG("XTC", "Generated 1-bit thumb BMP with Atkinson dithering (%dx%d): %s", thumbWidth, thumbHeight, + getThumbBmpPath(height).c_str()); return true; } @@ -515,6 +686,20 @@ size_t Xtc::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) con return const_cast(parser.get())->loadPage(pageIndex, buffer, bufferSize); } +size_t Xtc::loadPageMsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const { + if (!loaded || !parser) { + return 0; + } + return const_cast(parser.get())->loadPageMsb(pageIndex, buffer, bufferSize); +} + +size_t Xtc::loadPageLsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const { + if (!loaded || !parser) { + return 0; + } + return const_cast(parser.get())->loadPageLsb(pageIndex, buffer, bufferSize); +} + xtc::XtcError Xtc::loadPageStreaming(uint32_t pageIndex, std::function callback, size_t chunkSize) const { diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index 3dbd160b4f..912f6ff162 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -82,6 +82,8 @@ class Xtc { * @return Number of bytes read */ size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const; + size_t loadPageMsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const; + size_t loadPageLsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const; /** * Load page with streaming callback diff --git a/lib/Xtc/Xtc/XtcParser.cpp b/lib/Xtc/Xtc/XtcParser.cpp index 877d82370b..7cdd4df532 100644 --- a/lib/Xtc/Xtc/XtcParser.cpp +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -467,6 +467,134 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz return bytesRead; } +size_t XtcParser::loadPageMsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) { + if (m_bitDepth != 2) { + return loadPage(pageIndex, buffer, bufferSize); + } + + if (!m_isOpen) { + m_lastError = XtcError::FILE_NOT_FOUND; + return 0; + } + + if (pageIndex >= m_header.pageCount) { + m_lastError = XtcError::PAGE_OUT_OF_RANGE; + return 0; + } + + PageInfo page; + if (!readPageTableEntry(pageIndex, page)) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + + if (!ensureFileOpen()) { + m_lastError = XtcError::FILE_NOT_FOUND; + return 0; + } + + if (!m_file.seek(page.offset)) { + LOG_DBG("XTC", "Failed to seek to page %u at offset %lu", pageIndex, page.offset); + m_lastError = XtcError::READ_ERROR; + return 0; + } + + XtgPageHeader pageHeader; + size_t headerRead = m_file.read(reinterpret_cast(&pageHeader), sizeof(XtgPageHeader)); + if (headerRead != sizeof(XtgPageHeader)) { + LOG_DBG("XTC", "Failed to read page header for page %u", pageIndex); + m_lastError = XtcError::READ_ERROR; + return 0; + } + + if (pageHeader.magic != XTH_MAGIC) { + LOG_DBG("XTC", "Invalid page magic for page %u: 0x%08X", pageIndex, pageHeader.magic); + m_lastError = XtcError::INVALID_MAGIC; + return 0; + } + + // Only plane1 (MSB): (width * height + 7) / 8 bytes + const size_t planeSize = (static_cast(pageHeader.width) * pageHeader.height + 7) / 8; + + if (bufferSize < planeSize) { + LOG_DBG("XTC", "Buffer too small for MSB plane: need %u, have %u", planeSize, bufferSize); + m_lastError = XtcError::MEMORY_ERROR; + return 0; + } + + size_t bytesRead = m_file.read(buffer, planeSize); + if (bytesRead != planeSize) { + LOG_DBG("XTC", "MSB plane read error: expected %u, got %u", planeSize, bytesRead); + m_lastError = XtcError::READ_ERROR; + return 0; + } + + m_lastError = XtcError::OK; + return bytesRead; +} + +size_t XtcParser::loadPageLsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) { + if (m_bitDepth != 2) return loadPage(pageIndex, buffer, bufferSize); + + if (!m_isOpen) { + m_lastError = XtcError::FILE_NOT_FOUND; + return 0; + } + if (pageIndex >= m_header.pageCount) { + m_lastError = XtcError::PAGE_OUT_OF_RANGE; + return 0; + } + + PageInfo page; + if (!readPageTableEntry(pageIndex, page)) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + + if (!ensureFileOpen()) { + m_lastError = XtcError::FILE_NOT_FOUND; + return 0; + } + + if (!m_file.seek(page.offset)) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + + XtgPageHeader pageHeader; + if (m_file.read(reinterpret_cast(&pageHeader), sizeof(XtgPageHeader)) != sizeof(XtgPageHeader)) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + if (pageHeader.magic != XTH_MAGIC) { + LOG_DBG("XTC", "loadPageLsb: invalid magic for page %u: 0x%08X", pageIndex, pageHeader.magic); + m_lastError = XtcError::INVALID_MAGIC; + return 0; + } + + const size_t planeSize = (static_cast(pageHeader.width) * pageHeader.height + 7) / 8; + const uint32_t plane2Offset = page.offset + sizeof(XtgPageHeader) + static_cast(planeSize); + + if (!m_file.seek(plane2Offset)) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + + if (bufferSize < planeSize) { + LOG_DBG("XTC", "Buffer too small for LSB plane: need %u, have %u", planeSize, bufferSize); + m_lastError = XtcError::MEMORY_ERROR; + return 0; + } + + size_t bytesRead = m_file.read(buffer, planeSize); + if (bytesRead != planeSize) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + m_lastError = XtcError::OK; + return bytesRead; +} + XtcError XtcParser::loadPageStreaming(uint32_t pageIndex, std::function callback, size_t chunkSize) { diff --git a/lib/Xtc/Xtc/XtcParser.h b/lib/Xtc/Xtc/XtcParser.h index b688d79350..3901a72873 100644 --- a/lib/Xtc/Xtc/XtcParser.h +++ b/lib/Xtc/Xtc/XtcParser.h @@ -57,6 +57,29 @@ class XtcParser { */ size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize); + /** + * Load only plane1 (MSB) of a 2-bit page into buffer. + * For 1-bit pages behaves identically to loadPage. + * Requires only half the buffer of loadPage for 2-bit pages, fitting within heap limits. + * + * @param pageIndex Page index (0-based) + * @param buffer Output buffer (caller allocated, must hold planeSize bytes) + * @param bufferSize Buffer size + * @return Number of bytes read on success, 0 on failure + */ + size_t loadPageMsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize); + + /** + * Load only plane2 (LSB) of a 2-bit page into buffer. + * For 1-bit pages behaves identically to loadPage. + * + * @param pageIndex Page index (0-based) + * @param buffer Output buffer (caller allocated, must hold planeSize bytes) + * @param bufferSize Buffer size + * @return Number of bytes read on success, 0 on failure + */ + size_t loadPageLsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize); + /** * Streaming page load * Memory-efficient method that reads page data in chunks. diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index 665df5356b..d176c3de02 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -66,7 +66,7 @@ void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen); } -void HalDisplay::deepSleep() { einkDisplay.deepSleep(); } +void HalDisplay::deepSleep(const bool powerDownDisplay) { einkDisplay.deepSleep(powerDownDisplay); } uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); } @@ -80,7 +80,17 @@ void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.cleanupGrayscaleBuffers(bwBuffer); } -void HalDisplay::displayGrayBuffer(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); } +void HalDisplay::displayGrayBuffer(bool turnOffScreen, const unsigned char* lut, bool factoryMode) { + einkDisplay.displayGrayBuffer(turnOffScreen, lut, factoryMode); +} + +void HalDisplay::displayGrayBufferFactorySetup(const unsigned char* lut) { + einkDisplay.displayGrayBufferFactorySetup(lut); +} + +void HalDisplay::displayGrayBufferFactoryActivate() { einkDisplay.displayGrayBufferFactoryActivate(); } + +void HalDisplay::displayBufferPrecondition(uint8_t color) { einkDisplay.displayBufferPrecondition(color); } uint16_t HalDisplay::getDisplayWidth() const { return einkDisplay.getDisplayWidth(); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index a0a7f92083..fc8cee8c29 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -37,7 +37,7 @@ class HalDisplay { void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); // Power management - void deepSleep(); + void deepSleep(bool powerDownDisplay = true); // Access to frame buffer uint8_t* getFrameBuffer() const; @@ -47,7 +47,17 @@ class HalDisplay { void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer); void cleanupGrayscaleBuffers(const uint8_t* bwBuffer); - void displayGrayBuffer(bool turnOffScreen = false); + void displayGrayBuffer(bool turnOffScreen = false, const unsigned char* lut = nullptr, bool factoryMode = false); + // Two-phase factory grayscale render — see EInkDisplay.h. + void displayGrayBufferFactorySetup(const unsigned char* lut); + void displayGrayBufferFactoryActivate(); + // Stock-V5.5.9 byte-match precondition (black/white full power-cycle flash). + void displayBufferPrecondition(uint8_t color); + + // Tell the SDK that grayscale state has been cleaned up by the consumer + // (RAM banks rebased + a follow-up FAST_REFRESH will handle pixel cleanup), + // so the next displayBuffer() should not run grayscaleRevert(). + void clearGrayscaleModeFlag() { einkDisplay.clearGrayscaleModeFlag(); } // Runtime geometry passthrough uint16_t getDisplayWidth() const; diff --git a/open-x4-sdk b/open-x4-sdk index a64a3c29be..b4b9d39190 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit a64a3c29bebc59b2ccdfe15492cfc4b5e4c26360 +Subproject commit b4b9d391908f5afdcc74db267cc371f1fb9e2991 diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 0b7690055b..d7e178eecf 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -63,6 +63,7 @@ class CrossPointSettings { XTC_STATUS_BAR_TOP = 2, XTC_STATUS_BAR_MODE_COUNT }; + enum XTC_RENDER_QUALITY { XTC_RENDER_FAST = 0, XTC_RENDER_QUALITY_HIGH = 1, XTC_RENDER_QUALITY_COUNT }; enum ORIENTATION { PORTRAIT = 0, // 480x800 logical coordinates (current default) @@ -169,6 +170,7 @@ class CrossPointSettings { uint8_t statusBarTitle = CHAPTER_TITLE; uint8_t statusBarBattery = 1; uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE; + uint8_t xtcRenderQuality = XTC_RENDER_FAST; // Text rendering settings uint8_t extraParagraphSpacing = 1; uint8_t textAntiAliasing = 1; diff --git a/src/SettingsList.h b/src/SettingsList.h index d8a118c372..4ed466138c 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -159,6 +159,8 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering, {StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS}, "imageRendering", StrId::STR_CAT_READER), + SettingInfo::Enum(StrId::STR_XTC_RENDER_QUALITY, &CrossPointSettings::xtcRenderQuality, + {StrId::STR_SPEED, StrId::STR_QUALITY}, "xtcRenderQuality", StrId::STR_CAT_READER), // --- Controls --- SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout, {StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV}, "sideButtonLayout", StrId::STR_CAT_CONTROLS), diff --git a/src/activities/Activity.h b/src/activities/Activity.h index 33a4495203..55595a0089 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -46,6 +46,11 @@ class Activity { virtual bool isReaderActivity() const { return false; } virtual ScreenshotInfo getScreenshotInfo() const { return {}; } + // Called when the screenshot combo is pressed and the display is in FactoryLut state. + // Activities that use renderGrayscaleSinglePass should override this to trigger a re-render, + // allowing the installed screenshot hook to capture both planes before they are pushed. + virtual void onScreenshotRequest() {} + // Start a new activity without destroying the current one // Note: requestUpdate() will be invoked automatically once resultHandler finishes void startActivityForResult(std::unique_ptr&& activity, ActivityResultHandler resultHandler); diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index a8d737b80b..3227bb97ad 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -97,6 +97,8 @@ class ActivityManager { // Note: if popActivity() on last activity on the stack, we will goHome() void popActivity(); + Activity* getCurrentActivity() const { return currentActivity.get(); } + bool preventAutoSleep() const; bool isReaderActivity() const; bool skipLoopDelay() const; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 19f443b064..877eaf1e5c 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -10,21 +11,34 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "Epub/converters/DirectPixelWriter.h" #include "activities/reader/ReaderUtils.h" -#include "components/UITheme.h" #include "fontIds.h" #include "images/Logo120.h" +namespace { +constexpr uint8_t SLEEP_FACTORY_INTERNAL_PREFLASH_PASSES = 0; + +// Stock V5.5.9 byte-match: black flash then white flash, each via the new +// EInkDisplay::displayBufferPrecondition() path which fires CTRL2=0xF7 (full +// power cycle) and skips the SINGLE_BUFFER_MODE post-RED-sync. Matches stock's +// precondition function at firmware addr 0x42010096 — see +// docs/v559-disassembly-findings.md. +constexpr uint8_t FACTORY_SLEEP_PRECONDITION_COLORS[] = {0x00, 0xFF}; + +void runFactorySleepPrecondition(const GfxRenderer& renderer) { + for (const uint8_t color : FACTORY_SLEEP_PRECONDITION_COLORS) { + renderer.displayBufferPrecondition(color); + } +} +} // namespace + void SleepActivity::onEnter() { Activity::onEnter(); - // Show popup with reader orientation only when going to sleep from reader if (APP_STATE.lastSleepFromReader) { ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); - GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); renderer.setOrientation(GfxRenderer::Orientation::Portrait); - } else { - GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); } switch (SETTINGS.sleepScreen) { @@ -50,20 +64,38 @@ void SleepActivity::renderCustomSleepScreen() const { const char* sleepDir = nullptr; auto dir = Storage.open("/.sleep"); + // Check root for sleep.pxc (preferred) or sleep.bmp before scanning the directory. + if (Storage.exists("/sleep.pxc")) { + LOG_DBG("SLP", "Loading: /sleep.pxc"); + if (dir) dir.close(); + if (renderPxcSleepScreen("/sleep.pxc")) { + return; + } + renderDefaultSleepScreen(); + return; + } + // Look for sleep.bmp on the root of the sd card to determine if we should // render a custom sleep screen instead of the default. // This takes priority over the /sleep folder. - FsFile file; - if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) { - Bitmap bitmap(file, true); - if (bitmap.parseHeaders() == BmpReaderError::Ok) { - LOG_DBG("SLP", "Loading: /sleep.bmp"); - renderBitmapSleepScreen(bitmap); + { + FsFile file; + if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) { + Bitmap bitmap(file, true); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + LOG_DBG("SLP", "Loading: /sleep.bmp"); + if (bitmap.hasGreyscale() && + SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER) { + lastGrayscalePath = "/sleep.bmp"; + lastGrayscaleIsPxc = false; + } + renderBitmapSleepScreen(bitmap); + file.close(); + if (dir) dir.close(); + return; + } file.close(); - if (dir) dir.close(); - return; } - file.close(); } if (dir && dir.isDirectory()) { @@ -78,7 +110,7 @@ void SleepActivity::renderCustomSleepScreen() const { if (sleepDir) { std::vector files; char name[500]; - // collect all valid BMP files + // collect all valid BMP/PXC files for (auto dirFile = dir.openNextFile(); dirFile; dirFile = dir.openNextFile()) { if (dirFile.isDirectory()) { dirFile.close(); @@ -91,16 +123,35 @@ void SleepActivity::renderCustomSleepScreen() const { continue; } - if (!FsHelpers::hasBmpExtension(filename)) { - LOG_DBG("SLP", "Skipping non-.bmp file name: %s", name); + const bool isBmp = FsHelpers::hasBmpExtension(filename); + const bool isPxc = FsHelpers::hasPxcExtension(filename); + if (!isBmp && !isPxc) { + LOG_DBG("SLP", "Skipping non-BMP/PXC file: %s", name); dirFile.close(); continue; } - Bitmap bitmap(dirFile); - if (bitmap.parseHeaders() != BmpReaderError::Ok) { - LOG_DBG("SLP", "Skipping invalid BMP file: %s", name); - dirFile.close(); - continue; + if (isBmp) { + Bitmap bitmap(dirFile); + if (bitmap.parseHeaders() != BmpReaderError::Ok) { + LOG_DBG("SLP", "Skipping invalid BMP file: %s", name); + dirFile.close(); + continue; + } + } + if (isPxc) { + uint16_t w, h; + if (dirFile.read(&w, 2) != 2 || dirFile.read(&h, 2) != 2) { + LOG_DBG("SLP", "Skipping PXC with unreadable header: %s", name); + dirFile.close(); + continue; + } + const int sw = renderer.getScreenWidth(); + const int sh = renderer.getScreenHeight(); + if (w != sw || h != sh) { + LOG_DBG("SLP", "Skipping PXC size mismatch %dx%d (screen %dx%d): %s", w, h, sw, sh, name); + dirFile.close(); + continue; + } } files.emplace_back(filename); dirFile.close(); @@ -119,12 +170,24 @@ void SleepActivity::renderCustomSleepScreen() const { APP_STATE.pushRecentSleep(randomFileIndex); APP_STATE.saveToFile(); const auto filename = std::string(sleepDir) + "/" + files[randomFileIndex]; + LOG_DBG("SLP", "Randomly loading: %s/%s", sleepDir, files[randomFileIndex].c_str()); + delay(100); + if (FsHelpers::hasPxcExtension(files[randomFileIndex])) { + dir.close(); + if (!renderPxcSleepScreen(filename)) { + renderDefaultSleepScreen(); + } + return; + } FsFile randFile; if (Storage.openFileForRead("SLP", filename, randFile)) { - LOG_DBG("SLP", "Randomly loading: %s/%s", sleepDir, files[randomFileIndex].c_str()); - delay(100); Bitmap bitmap(randFile, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { + if (bitmap.hasGreyscale() && + SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER) { + lastGrayscalePath = filename; + lastGrayscaleIsPxc = false; + } renderBitmapSleepScreen(bitmap); randFile.close(); dir.close(); @@ -156,6 +219,73 @@ void SleepActivity::renderDefaultSleepScreen() const { renderer.displayBuffer(HalDisplay::HALF_REFRESH); } +bool SleepActivity::renderPxcSleepScreen(const std::string& path) const { + FsFile file; + if (!Storage.openFileForRead("SLP", path, file)) { + LOG_ERR("SLP", "Cannot open PXC: %s", path.c_str()); + return false; + } + + uint16_t pxcWidth, pxcHeight; + if (file.read(&pxcWidth, 2) != 2 || file.read(&pxcHeight, 2) != 2) { + LOG_ERR("SLP", "PXC header read failed: %s", path.c_str()); + file.close(); + return false; + } + + const int screenWidth = renderer.getScreenWidth(); + const int screenHeight = renderer.getScreenHeight(); + if (pxcWidth != screenWidth || pxcHeight != screenHeight) { + LOG_ERR("SLP", "PXC size %dx%d does not match screen %dx%d", pxcWidth, pxcHeight, screenWidth, screenHeight); + file.close(); + return false; + } + + const uint32_t dataOffset = file.position(); // right after the 4-byte header + + // PXC is always 2-bit grayscale - always use factory LUT + lastGrayscalePath = path; + lastGrayscaleIsPxc = true; + struct PxcCtx { + FsFile* file; + uint32_t dataOffset; + int width, height; + }; + PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight}; + + runFactorySleepPrecondition(renderer); + renderer.renderGrayscaleSinglePass( + GfxRenderer::GrayscaleDriveMode::FactoryQuality, + [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + c->file->seek(c->dataOffset); + + const int bpr = (c->width + 3) / 4; + uint8_t* rowBuf = static_cast(malloc(bpr)); + if (!rowBuf) { + LOG_ERR("SLP", "malloc failed for rowBuf (%d bytes, %dx%d)", bpr, c->width, c->height); + return; + } + + DirectPixelWriter pw; + pw.init(r); + + for (int row = 0; row < c->height; row++) { + if (c->file->read(rowBuf, bpr) != bpr) break; + pw.beginRow(row); + for (int col = 0; col < c->width; col++) { + const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; + pw.writePixel(pv); + } + } + free(rowBuf); + }, + &ctx, nullptr, nullptr, HalDisplay::FULL_REFRESH, SLEEP_FACTORY_INTERNAL_PREFLASH_PASSES); + + file.close(); + return true; +} + void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { int x, y; const auto pageWidth = renderer.getScreenWidth(); @@ -197,34 +327,32 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { } LOG_DBG("SLP", "drawing to %d x %d", x, y); - renderer.clearScreen(); const bool hasGreyscale = bitmap.hasGreyscale() && SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER; - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); - - if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) { - renderer.invertScreen(); - } - - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - if (hasGreyscale) { - bitmap.rewindToData(); - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); - renderer.copyGrayscaleLsbBuffers(); - - bitmap.rewindToData(); - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + struct BitmapGrayCtx { + const Bitmap* bitmap; + int x, y, maxWidth, maxHeight; + float cropX, cropY; + }; + BitmapGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, cropX, cropY}; + runFactorySleepPrecondition(renderer); + renderer.renderGrayscaleSinglePass( + GfxRenderer::GrayscaleDriveMode::FactoryQuality, + [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, c->cropX, c->cropY); + }, + &grayCtx, nullptr, nullptr, HalDisplay::FULL_REFRESH, SLEEP_FACTORY_INTERNAL_PREFLASH_PASSES); + } else { + renderer.clearScreen(); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); - renderer.copyGrayscaleMsbBuffers(); - - renderer.displayGrayBuffer(); - renderer.setRenderMode(GfxRenderer::BW); + if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) { + renderer.invertScreen(); + } + renderer.displayBuffer(HalDisplay::FULL_REFRESH); } } @@ -248,13 +376,73 @@ void SleepActivity::renderCoverSleepScreen() const { // Check if the current book is XTC, TXT, or EPUB if (FsHelpers::hasXtcExtension(APP_STATE.openEpubPath)) { - // Handle XTC file Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastXtc.load()) { LOG_ERR("SLP", "Failed to load last XTC"); return (this->*renderNoCoverSleepScreen)(); } + if (lastXtc.getBitDepth() == 2) { + const size_t planeSize = (static_cast(lastXtc.getPageWidth()) * lastXtc.getPageHeight() + 7) / 8; + uint8_t* plane1 = static_cast(malloc(planeSize)); + if (!plane1) { + LOG_ERR("SLP", "Failed to alloc plane1 for direct XTCH render (%lu bytes)", + static_cast(planeSize)); + return (this->*renderNoCoverSleepScreen)(); + } + uint8_t* plane2 = static_cast(malloc(planeSize)); + if (!plane2) { + LOG_ERR("SLP", "Failed to alloc plane2 for direct XTCH render (%lu bytes)", + static_cast(planeSize)); + free(plane1); + return (this->*renderNoCoverSleepScreen)(); + } + + if (lastXtc.loadPageMsb(0, plane1, planeSize) == 0) { + LOG_ERR("SLP", "Failed to load XTCH plane1 for sleep cover"); + free(plane1); + free(plane2); + return (this->*renderNoCoverSleepScreen)(); + } + if (lastXtc.loadPageLsb(0, plane2, planeSize) == 0) { + LOG_ERR("SLP", "Failed to load XTCH plane2 for sleep cover"); + free(plane1); + free(plane2); + return (this->*renderNoCoverSleepScreen)(); + } + + LOG_DBG("SLP", "Direct XTCH plane render: %ux%u", lastXtc.getPageWidth(), lastXtc.getPageHeight()); + runFactorySleepPrecondition(renderer); + renderer.displayXtchPlanes(plane1, plane2, lastXtc.getPageWidth(), lastXtc.getPageHeight(), nullptr, nullptr, + GfxRenderer::GrayscaleDriveMode::FactoryQuality, false); + free(plane1); + free(plane2); + return; + } + + if (lastXtc.getBitDepth() == 1) { + const size_t bufferSize = (static_cast(lastXtc.getPageWidth() + 7) / 8) * lastXtc.getPageHeight(); + uint8_t* pageBuffer = static_cast(malloc(bufferSize)); + if (!pageBuffer) { + LOG_ERR("SLP", "Failed to alloc page buffer for direct XTC render (%lu bytes)", + static_cast(bufferSize)); + return (this->*renderNoCoverSleepScreen)(); + } + if (lastXtc.loadPage(0, pageBuffer, bufferSize) == 0) { + LOG_ERR("SLP", "Failed to load XTC page for sleep cover"); + free(pageBuffer); + return (this->*renderNoCoverSleepScreen)(); + } + LOG_DBG("SLP", "Direct XTC page render: %ux%u", lastXtc.getPageWidth(), lastXtc.getPageHeight()); + if (!APP_STATE.lastSleepFromReader) { + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + } + renderer.displayXtcBwPage(pageBuffer, lastXtc.getPageWidth(), lastXtc.getPageHeight()); + free(pageBuffer); + return; + } + if (!lastXtc.generateCoverBmp()) { LOG_ERR("SLP", "Failed to generate XTC cover bmp"); return (this->*renderNoCoverSleepScreen)(); @@ -299,6 +487,11 @@ void SleepActivity::renderCoverSleepScreen() const { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str()); + if (bitmap.hasGreyscale() && + SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER) { + lastGrayscalePath = coverBmpPath; + lastGrayscaleIsPxc = false; + } renderBitmapSleepScreen(bitmap); return; } @@ -311,3 +504,24 @@ void SleepActivity::renderBlankSleepScreen() const { renderer.clearScreen(); renderer.displayBuffer(HalDisplay::HALF_REFRESH); } + +void SleepActivity::onScreenshotRequest() { + if (lastGrayscalePath.empty()) return; + if (lastGrayscaleIsPxc) { + if (!renderPxcSleepScreen(lastGrayscalePath)) { + renderDefaultSleepScreen(); + } + } else { + FsFile file; + if (Storage.openFileForRead("SLP", lastGrayscalePath.c_str(), file)) { + Bitmap bitmap(file, true); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + renderBitmapSleepScreen(bitmap); + } + file.close(); + } + } + // Device enters deep sleep next; on wake the new activity will full-refresh anyway. + renderer.clearScreen(); + renderer.cleanupGrayscaleWithFrameBuffer(); +} diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 87df8ba19d..2952009341 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -1,4 +1,6 @@ #pragma once +#include + #include "../Activity.h" class Bitmap; @@ -8,11 +10,17 @@ class SleepActivity final : public Activity { explicit SleepActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) : Activity("Sleep", renderer, mappedInput) {} void onEnter() override; + void onScreenshotRequest() override; private: void renderDefaultSleepScreen() const; void renderCustomSleepScreen() const; void renderCoverSleepScreen() const; void renderBitmapSleepScreen(const Bitmap& bitmap) const; + bool renderPxcSleepScreen(const std::string& path) const; void renderBlankSleepScreen() const; + + // Tracks the last factory-LUT render so onScreenshotRequest() can re-render the same image. + mutable std::string lastGrayscalePath; + mutable bool lastGrayscaleIsPxc = false; }; diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 870e93b6dc..5737fdca82 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -16,6 +16,11 @@ namespace { constexpr unsigned long GO_HOME_MS = 1000; + +bool isSupportedFile(std::string_view name) { + return FsHelpers::hasEpubExtension(name) || FsHelpers::hasXtcExtension(name) || FsHelpers::hasTxtExtension(name) || + FsHelpers::hasMarkdownExtension(name) || FsHelpers::hasBmpExtension(name) || FsHelpers::hasPxcExtension(name); +} } // namespace void FileBrowserActivity::loadFiles() { @@ -44,9 +49,7 @@ void FileBrowserActivity::loadFiles() { if (FsHelpers::checkFileExtension(filename, ".bin")) { files.emplace_back(filename); } - } else if (FsHelpers::hasEpubExtension(filename) || FsHelpers::hasXtcExtension(filename) || - FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename) || - FsHelpers::hasBmpExtension(filename)) { + } else if (isSupportedFile(filename)) { files.emplace_back(filename); } } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 7732762bd4..d010270527 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -47,6 +47,12 @@ int clampPercent(int percent) { } // namespace +void EpubReaderActivity::renderPageCallback(const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + c->page->render(const_cast(r), c->fontId, c->left, c->top); + c->activity->renderStatusBar(); +} + void EpubReaderActivity::onEnter() { Activity::onEnter(); @@ -727,7 +733,12 @@ void EpubReaderActivity::render(RenderLock&& lock) { if (pendingScreenshot) { pendingScreenshot = false; - ScreenshotUtil::takeScreenshot(renderer); + if (lastPageWasFactoryGray) { + ScreenshotUtil::prepareFactoryLutScreenshot(renderer); + onScreenshotRequest(); + } else { + ScreenshotUtil::takeScreenshot(renderer); + } } } @@ -778,32 +789,16 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or scope.endScanAndPrewarm(); const auto tPrewarm = millis(); - // Force special handling for pages with images when anti-aliasing is on - bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing; - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); renderStatusBar(); const auto tBwRender = millis(); - if (imagePageWithAA) { - // Double FAST_REFRESH with selective image blanking (pablohc's technique): - // HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust. - // Instead, blank only the image area and do two fast refreshes. - // Step 1: Display page with image area blanked (text appears, image area white) - // Step 2: Re-render with images and display again (images appear clean) - int16_t imgX, imgY, imgW, imgH; - if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) { - renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false); - renderer.displayBuffer(HalDisplay::FAST_REFRESH); - - // Re-render page content to restore images into the blanked area - // Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %) - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderer.displayBuffer(HalDisplay::FAST_REFRESH); - } else { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - } - // Double FAST_REFRESH handles ghosting for image pages; don't count toward full refresh cadence + const bool isImagePage = page->hasImages(); + const bool useFactoryGray = SETTINGS.textAntiAliasing && isImagePage; + lastPageWasFactoryGray = useFactoryGray; + if (useFactoryGray) { + lastFactoryMarginTop = orientedMarginTop; + lastFactoryMarginLeft = orientedMarginLeft; } else { ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); } @@ -814,35 +809,28 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or const auto tBwStore = millis(); // grayscale rendering - // TODO: Only do this if font supports it if (SETTINGS.textAntiAliasing) { - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderer.copyGrayscaleLsbBuffers(); - const auto tGrayLsb = millis(); - - // Render and copy to MSB buffer - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderer.copyGrayscaleMsbBuffers(); - const auto tGrayMsb = millis(); - - // display grayscale part - renderer.displayGrayBuffer(); - const auto tGrayDisplay = millis(); - renderer.setRenderMode(GfxRenderer::BW); - // restore the bw data + PageRenderCtx grayCtx{page.get(), SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop, this}; + + const auto tGrayStart = millis(); + const auto grayMode = useFactoryGray ? GfxRenderer::GrayscaleDriveMode::FactoryQuality + : GfxRenderer::GrayscaleDriveMode::Differential; + renderer.renderGrayscale(grayMode, &renderPageCallback, &grayCtx); + const auto tGrayEnd = millis(); + fcm->logStats(useFactoryGray ? "gray_factory_quality" : "gray"); + + // restoreBwBuffer() copies the saved BW frame back AND rebases RED RAM + // via cleanupGrayscaleBuffers, which is sufficient cleanup for both + // differential and factory paths — no extra RED RAM write needed. renderer.restoreBwBuffer(); const auto tBwRestore = millis(); const auto tEnd = millis(); LOG_DBG("ERS", - "Page render: prewarm=%lums bw_render=%lums display=%lums bw_store=%lums " - "gray_lsb=%lums gray_msb=%lums gray_display=%lums bw_restore=%lums total=%lums", - tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tGrayLsb - tBwStore, - tGrayMsb - tGrayLsb, tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0); + "Page render (%s): prewarm=%lums bw_render=%lums display=%lums bw_store=%lums " + "gray=%lums bw_restore=%lums total=%lums", + useFactoryGray ? "factory" : "diff", tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, + tBwStore - tDisplay, tGrayEnd - tGrayStart, tBwRestore - tGrayEnd, tEnd - t0); } else { // restore the bw data renderer.restoreBwBuffer(); @@ -856,6 +844,22 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or } } +void EpubReaderActivity::onScreenshotRequest() { + if (!section || !lastPageWasFactoryGray) return; + + auto p = section->loadPageFromSectionFile(); + if (!p) return; + + // Preserve the BW page across the gray render so restoreBwBuffer's + // cleanupGrayscaleBuffers call rebases RED RAM to the actual page, not a + // cleared framebuffer. + if (!renderer.storeBwBuffer()) return; + + PageRenderCtx grayCtx{p.get(), SETTINGS.getReaderFontId(), lastFactoryMarginLeft, lastFactoryMarginTop, this}; + renderer.renderGrayscale(GfxRenderer::GrayscaleDriveMode::FactoryQuality, &renderPageCallback, &grayCtx); + renderer.restoreBwBuffer(); +} + void EpubReaderActivity::renderStatusBar() const { // Calculate progress in book const int currentPage = section->currentPage + 1; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 01de3fde45..fbe59566cb 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -31,6 +31,10 @@ class EpubReaderActivity final : public Activity { bool pendingSyncSaveError = false; bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit bool automaticPageTurnActive = false; + // Context saved from the last factory-gray image page render, used by onScreenshotRequest(). + bool lastPageWasFactoryGray = false; + int lastFactoryMarginTop = 0; + int lastFactoryMarginLeft = 0; // Footnote support std::vector currentPageFootnotes; @@ -42,6 +46,13 @@ class EpubReaderActivity final : public Activity { SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {}; int footnoteDepth = 0; + struct PageRenderCtx { + Page* page; + int fontId, left, top; + const EpubReaderActivity* activity; + }; + static void renderPageCallback(const GfxRenderer& r, const void* raw); + void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); void renderStatusBar() const; @@ -65,6 +76,7 @@ class EpubReaderActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&& lock) override; + void onScreenshotRequest() override; bool isReaderActivity() const override { return true; } ScreenshotInfo getScreenshotInfo() const override; }; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 989e6d2231..aa3c5d03fb 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -12,6 +12,7 @@ #include "XtcReaderActivity.h" #include "activities/util/BmpViewerActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "activities/util/PxcViewerActivity.h" bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasXtcExtension(path); } @@ -22,6 +23,8 @@ bool ReaderActivity::isTxtFile(const std::string& path) { bool ReaderActivity::isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); } +bool ReaderActivity::isPxcFile(const std::string& path) { return FsHelpers::hasPxcExtension(path); } + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!Storage.exists(path.c_str())) { LOG_ERR("READER", "File does not exist: %s", path.c_str()); @@ -83,6 +86,10 @@ void ReaderActivity::onGoToBmpViewer(const std::string& path) { activityManager.replaceActivity(std::make_unique(renderer, mappedInput, path)); } +void ReaderActivity::onGoToPxcViewer(const std::string& path) { + activityManager.replaceActivity(std::make_unique(renderer, mappedInput, path)); +} + void ReaderActivity::onGoToXtcReader(std::unique_ptr xtc) { const auto xtcPath = xtc->getPath(); currentBookPath = xtcPath; @@ -106,6 +113,8 @@ void ReaderActivity::onEnter() { currentBookPath = initialBookPath; if (isBmpFile(initialBookPath)) { onGoToBmpViewer(initialBookPath); + } else if (isPxcFile(initialBookPath)) { + onGoToPxcViewer(initialBookPath); } else if (isXtcFile(initialBookPath)) { auto xtc = loadXtc(initialBookPath); if (!xtc) { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index f5c61a393d..e4cf6194b2 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -17,12 +17,14 @@ class ReaderActivity final : public Activity { static bool isXtcFile(const std::string& path); static bool isTxtFile(const std::string& path); static bool isBmpFile(const std::string& path); + static bool isPxcFile(const std::string& path); void goToLibrary(const std::string& fromBookPath = ""); void onGoToEpubReader(std::unique_ptr epub); void onGoToXtcReader(std::unique_ptr xtc); void onGoToTxtReader(std::unique_ptr txt); void onGoToBmpViewer(const std::string& path); + void onGoToPxcViewer(const std::string& path); void onGoBack(); diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 1de6174b5a..b483358383 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -57,6 +57,7 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) { inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh) { if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(HalDisplay::HALF_REFRESH); + renderer.cleanupGrayscaleWithFrameBuffer(); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 897d3f52c2..0c0a4e1331 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -32,6 +32,10 @@ void XtcReaderActivity::onEnter() { xtc->setupCacheDir(); + // Pre-flash to white so the factory LUT can drive particles reliably from any prior state. + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + // Load saved progress loadProgress(); @@ -61,6 +65,7 @@ void XtcReaderActivity::loop() { [this](const ActivityResult& result) { if (!result.isCancelled) { currentPage = std::get(result.data).page; + halfRefreshBeforeNextPage = xtc && xtc->getBitDepth() == 2; } }); } @@ -133,6 +138,10 @@ void XtcReaderActivity::render(RenderLock&&) { saveProgress(); } +void XtcReaderActivity::renderStatusBarOverlayCallback(const GfxRenderer&, const void* raw) { + const auto* activity = static_cast(raw); + activity->renderConfiguredStatusBarOverlay(); +} XtcReaderActivity::StatusBarInfo XtcReaderActivity::getStatusBarInfo() const { const int bookPageCount = static_cast(xtc->getPageCount()); const int bookPage = static_cast(currentPage) + 1; @@ -203,22 +212,82 @@ void XtcReaderActivity::renderStatusBarOverlay(const StatusBarOverlayPosition po GUI.drawStatusBar(renderer, progress, pageInfo.currentPage, pageInfo.pageCount, pageInfo.title, paddingBottom); } +void XtcReaderActivity::renderConfiguredStatusBarOverlay() const { + renderStatusBarOverlay(StatusBarOverlayPosition::Top); + renderStatusBarOverlay(StatusBarOverlayPosition::Bottom); +} void XtcReaderActivity::renderPage() { const uint16_t pageWidth = xtc->getPageWidth(); const uint16_t pageHeight = xtc->getPageHeight(); const uint8_t bitDepth = xtc->getBitDepth(); - // Calculate buffer size for one page - // XTG (1-bit): Row-major, ((width+7)/8) * height bytes - // XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes - size_t pageBufferSize; if (bitDepth == 2) { - pageBufferSize = ((static_cast(pageWidth) * pageHeight + 7) / 8) * 2; - } else { - pageBufferSize = ((pageWidth + 7) / 8) * pageHeight; + // Load each XTCH plane separately to stay within heap limits. + // Combined size (~96KB) exceeds MaxAlloc; each plane (~48KB) fits. + const size_t planeSize = (static_cast(pageWidth) * pageHeight + 7) / 8; + + uint8_t* plane1 = static_cast(malloc(planeSize)); + if (!plane1) { + LOG_ERR("XTR", "Failed to allocate plane1 (%lu bytes)", planeSize); + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_MEMORY_ERROR), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + if (xtc->loadPageMsb(currentPage, plane1, planeSize) == 0) { + LOG_ERR("XTR", "Failed to load plane1 for page %lu", currentPage); + free(plane1); + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + uint8_t* plane2 = static_cast(malloc(planeSize)); + if (!plane2) { + LOG_ERR("XTR", "Failed to allocate plane2 (%lu bytes)", planeSize); + free(plane1); + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_MEMORY_ERROR), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + if (xtc->loadPageLsb(currentPage, plane2, planeSize) == 0) { + LOG_ERR("XTR", "Failed to load plane2 for page %lu", currentPage); + free(plane1); + free(plane2); + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + if (halfRefreshBeforeNextPage) { + halfRefreshBeforeNextPage = false; + pagesSinceClean = 0; + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + } else if (++pagesSinceClean >= 32) { + // Periodic FULL_REFRESH resets DC balance; every 32 pages. + pagesSinceClean = 0; + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + } + + const auto xtcGrayMode = SETTINGS.xtcRenderQuality == CrossPointSettings::XTC_RENDER_QUALITY_HIGH + ? GfxRenderer::GrayscaleDriveMode::FactoryQuality + : GfxRenderer::GrayscaleDriveMode::FactoryFast; + renderer.displayXtchPlanes(plane1, plane2, pageWidth, pageHeight, + &XtcReaderActivity::renderStatusBarOverlayCallback, this, xtcGrayMode); + free(plane1); + free(plane2); + + LOG_DBG("XTR", "Rendered page %lu/%lu (2-bit factory)", currentPage + 1, xtc->getPageCount()); + return; } - // Allocate page buffer + // 1-bit XTG path + const size_t pageBufferSize = ((pageWidth + 7) / 8) * pageHeight; uint8_t* pageBuffer = static_cast(malloc(pageBufferSize)); if (!pageBuffer) { LOG_ERR("XTR", "Failed to allocate page buffer (%lu bytes)", pageBufferSize); @@ -227,10 +296,7 @@ void XtcReaderActivity::renderPage() { renderer.displayBuffer(); return; } - - // Load page data - size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize); - if (bytesRead == 0) { + if (xtc->loadPage(currentPage, pageBuffer, pageBufferSize) == 0) { LOG_ERR("XTR", "Failed to load page %lu: bufferSize=%lu bitDepth=%u error=%s", currentPage, pageBufferSize, bitDepth, xtc::errorToString(xtc->getLastError())); free(pageBuffer); @@ -239,141 +305,22 @@ void XtcReaderActivity::renderPage() { renderer.displayBuffer(); return; } - - // Clear screen first - renderer.clearScreen(); - - // Copy page bitmap using GfxRenderer's drawPixel - // XTC/XTCH pages are pre-rendered with status bar included, so render full page - const uint16_t maxSrcY = pageHeight; - - 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 - // - Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black - - const size_t planeSize = (static_cast(pageWidth) * pageHeight + 7) / 8; - const uint8_t* plane1 = pageBuffer; // Bit1 plane - const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane - const size_t colBytes = (pageHeight + 7) / 8; // Bytes per column (100 for 800 height) - - // Lambda to get pixel value at (x, y) - auto getPixelValue = [&](uint16_t x, uint16_t y) -> uint8_t { - const size_t colIndex = pageWidth - 1 - x; - const size_t byteInCol = y / 8; - const size_t bitInByte = 7 - (y % 8); - const size_t byteOffset = colIndex * colBytes + byteInCol; - const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; - const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; - return (bit1 << 1) | bit2; - }; - - // Optimized grayscale rendering without storeBwBuffer (saves 48KB peak memory) - // Flow: BW display → LSB/MSB passes → grayscale display → re-render BW for next frame - - // Count pixel distribution for debugging - uint32_t pixelCounts[4] = {0, 0, 0, 0}; - for (uint16_t y = 0; y < pageHeight; y++) { - for (uint16_t x = 0; x < pageWidth; x++) { - pixelCounts[getPixelValue(x, y)]++; - } - } - LOG_DBG("XTR", "Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu", pixelCounts[0], - pixelCounts[1], pixelCounts[2], pixelCounts[3]); - - // Pass 1: BW buffer - draw all non-white pixels as black - for (uint16_t y = 0; y < pageHeight; y++) { - for (uint16_t x = 0; x < pageWidth; x++) { - if (getPixelValue(x, y) >= 1) { - renderer.drawPixel(x, y, true); - } - } - } - - ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); - - // Pass 2: LSB buffer - mark DARK gray only (XTH value 1) - // In LUT: 0 bit = apply gray effect, 1 bit = untouched - renderer.clearScreen(0x00); - for (uint16_t y = 0; y < pageHeight; y++) { - for (uint16_t x = 0; x < pageWidth; x++) { - if (getPixelValue(x, y) == 1) { // Dark grey only - renderer.drawPixel(x, y, false); - } - } - } - renderer.copyGrayscaleLsbBuffers(); - - // Pass 3: MSB buffer - mark LIGHT AND DARK gray (XTH value 1 or 2) - // In LUT: 0 bit = apply gray effect, 1 bit = untouched - renderer.clearScreen(0x00); - for (uint16_t y = 0; y < pageHeight; y++) { - for (uint16_t x = 0; x < pageWidth; x++) { - const uint8_t pv = getPixelValue(x, y); - if (pv == 1 || pv == 2) { // Dark grey or Light grey - renderer.drawPixel(x, y, false); - } - } - } - renderer.copyGrayscaleMsbBuffers(); - - // Display grayscale overlay - renderer.displayGrayBuffer(); - - // Pass 4: Re-render BW to framebuffer (restore for next frame, instead of restoreBwBuffer) - renderer.clearScreen(); - for (uint16_t y = 0; y < pageHeight; y++) { - for (uint16_t x = 0; x < pageWidth; x++) { - if (getPixelValue(x, y) >= 1) { - renderer.drawPixel(x, y, true); - } - } - } - - // Cleanup grayscale buffers with current frame buffer - renderer.cleanupGrayscaleWithFrameBuffer(); - - free(pageBuffer); - - LOG_DBG("XTR", "Rendered page %lu/%lu (2-bit grayscale)", currentPage + 1, xtc->getPageCount()); - return; - } else { - // 1-bit mode: 8 pixels per byte, MSB first - const size_t srcRowBytes = (pageWidth + 7) / 8; // 60 bytes for 480 width - - for (uint16_t srcY = 0; srcY < maxSrcY; srcY++) { - const size_t srcRowStart = srcY * srcRowBytes; - - for (uint16_t srcX = 0; srcX < pageWidth; srcX++) { - // Read source pixel (MSB first, bit 7 = leftmost pixel) - const size_t srcByte = srcRowStart + srcX / 8; - const size_t srcBit = 7 - (srcX % 8); - const bool isBlack = !((pageBuffer[srcByte] >> srcBit) & 1); // XTC: 0 = black, 1 = white - - if (isBlack) { - renderer.drawPixel(srcX, srcY, true); - } - } - } - } - // White pixels are already cleared by clearScreen() - + const bool doFullRefresh = pagesUntilFullRefresh <= 1; + renderer.displayXtcBwPage(pageBuffer, pageWidth, pageHeight, + doFullRefresh ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH, + &XtcReaderActivity::renderStatusBarOverlayCallback, this); free(pageBuffer); - - if (SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP) { - renderStatusBarOverlay(StatusBarOverlayPosition::Top); + if (doFullRefresh) { + renderer.cleanupGrayscaleWithFrameBuffer(); + pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { - renderStatusBarOverlay(StatusBarOverlayPosition::Bottom); + pagesUntilFullRefresh--; } - - ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); - - LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth); + LOG_DBG("XTR", "Rendered page %lu/%lu (1-bit)", currentPage + 1, xtc->getPageCount()); } +void XtcReaderActivity::onScreenshotRequest() { renderPage(); } + void XtcReaderActivity::saveProgress() const { FsFile f; if (Storage.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) { diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index f020b0b04a..734d4065e0 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -19,6 +19,8 @@ class XtcReaderActivity final : public Activity { uint32_t currentPage = 0; int pagesUntilFullRefresh = 0; + uint32_t pagesSinceClean = 0; + bool halfRefreshBeforeNextPage = false; enum class StatusBarOverlayPosition { Bottom, Top }; struct StatusBarInfo { @@ -27,8 +29,10 @@ class XtcReaderActivity final : public Activity { std::string title; }; + static void renderStatusBarOverlayCallback(const GfxRenderer& renderer, const void* raw); void renderPage(); void renderStatusBarOverlay(StatusBarOverlayPosition position) const; + void renderConfiguredStatusBarOverlay() const; StatusBarInfo getStatusBarInfo() const; void saveProgress() const; void loadProgress(); @@ -40,6 +44,7 @@ class XtcReaderActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; + void onScreenshotRequest() override; bool isReaderActivity() const override { return true; } ScreenshotInfo getScreenshotInfo() const override; }; diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index 0387bc5544..29d37d98b6 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -104,18 +105,44 @@ void BmpViewerActivity::onEnter() { const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), (hasPrevious ? "<" : ""), (hasNext ? ">" : "")); - GUI.fillPopupProgress(renderer, popupRect, 50); - - renderer.clearScreen(); - // Assuming drawBitmap defaults to 0,0 crop if omitted, or pass explicitly: drawBitmap(bitmap, x, y, pageWidth, - // pageHeight, 0, 0) - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); - - // Draw UI hints on the base layer - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - // Single pass for non-grayscale images - - renderer.displayBuffer(HalDisplay::FAST_REFRESH); + if (bitmap.hasGreyscale()) { + struct BmpGrayCtx { + Bitmap* bitmap; + int x, y, maxWidth, maxHeight; + MappedInputManager::Labels labels; + }; + BmpGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, labels}; + renderer.renderGrayscaleSinglePass( + GfxRenderer::GrayscaleDriveMode::FactoryQuality, + [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, 0, 0); + GUI.drawButtonHints(const_cast(r), c->labels.btn1, c->labels.btn2, c->labels.btn3, + c->labels.btn4); + }, + &grayCtx, + [](const GfxRenderer& r, const void*) { + constexpr int margin = 15; + const char* msg = tr(STR_LOADING_POPUP); + const int y = static_cast(r.getScreenHeight() * 0.075f); + const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); + const int w = textWidth + margin * 2; + const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; + const int x = (r.getScreenWidth() - w) / 2; + r.fillRect(x - 2, y - 2, w + 4, h + 4, true); + r.fillRect(x, y, w, h, false); + r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); + }, + nullptr); + renderer.clearScreen(); + renderer.cleanupGrayscaleWithFrameBuffer(); + } else { + GUI.fillPopupProgress(renderer, popupRect, 50); + renderer.clearScreen(); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + } } else { // Handle file parsing error @@ -137,6 +164,74 @@ void BmpViewerActivity::onEnter() { } } +void BmpViewerActivity::renderGrayscaleImage() { + FsFile file; + if (!Storage.openFileForRead("BMP", filePath, file)) return; + + Bitmap bitmap(file, true); + if (bitmap.parseHeaders() != BmpReaderError::Ok || !bitmap.hasGreyscale()) { + file.close(); + return; + } + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + int x, y; + if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { + float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); + if (ratio > screenRatio) { + x = 0; + y = std::round((static_cast(pageHeight) - static_cast(pageWidth) / ratio) / 2); + } else { + x = std::round((static_cast(pageWidth) - static_cast(pageHeight) * ratio) / 2); + y = 0; + } + } else { + x = (pageWidth - bitmap.getWidth()) / 2; + y = (pageHeight - bitmap.getHeight()) / 2; + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), "", ""); + struct BmpGrayCtx { + Bitmap* bitmap; + int x, y, maxWidth, maxHeight; + MappedInputManager::Labels labels; + }; + BmpGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, labels}; + + renderer.renderGrayscaleSinglePass( + GfxRenderer::GrayscaleDriveMode::FactoryQuality, + [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, 0, 0); + GUI.drawButtonHints(const_cast(r), c->labels.btn1, c->labels.btn2, c->labels.btn3, + c->labels.btn4); + }, + &grayCtx, + [](const GfxRenderer& r, const void*) { + constexpr int margin = 15; + const char* msg = tr(STR_LOADING_POPUP); + const int y = static_cast(r.getScreenHeight() * 0.075f); + const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); + const int w = textWidth + margin * 2; + const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; + const int x = (r.getScreenWidth() - w) / 2; + r.fillRect(x - 2, y - 2, w + 4, h + 4, true); + r.fillRect(x, y, w, h, false); + r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); + }, + nullptr); + + file.close(); +} + +void BmpViewerActivity::onScreenshotRequest() { + renderGrayscaleImage(); + renderer.clearScreen(); + renderer.cleanupGrayscaleWithFrameBuffer(); +} + void BmpViewerActivity::onExit() { Activity::onExit(); renderer.clearScreen(); @@ -214,4 +309,4 @@ void BmpViewerActivity::loop() { } return; } -} \ No newline at end of file +} diff --git a/src/activities/util/BmpViewerActivity.h b/src/activities/util/BmpViewerActivity.h index f805d3756e..7b2316205c 100644 --- a/src/activities/util/BmpViewerActivity.h +++ b/src/activities/util/BmpViewerActivity.h @@ -13,12 +13,14 @@ class BmpViewerActivity final : public Activity { void onEnter() override; void onExit() override; void loop() override; + void onScreenshotRequest() override; private: void loadSiblingImages(); void doSetSleepCover(); + void renderGrayscaleImage(); std::string filePath; std::vector siblingImages; int currentImageIndex = -1; -}; \ No newline at end of file +}; diff --git a/src/activities/util/PxcViewerActivity.cpp b/src/activities/util/PxcViewerActivity.cpp new file mode 100644 index 0000000000..e3ba8cf340 --- /dev/null +++ b/src/activities/util/PxcViewerActivity.cpp @@ -0,0 +1,277 @@ +#include "PxcViewerActivity.h" + +#include +#include +#include +#include +#include + +#include "CrossPointSettings.h" +#include "Epub/converters/DirectPixelWriter.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +struct PxcCtx { + FsFile* file; + uint32_t dataOffset; + int width, height; + MappedInputManager::Labels labels; +}; + +void pxcRenderCallback(const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + c->file->seek(c->dataOffset); + + const int bytesPerRow = (c->width + 3) / 4; + uint8_t* rowBuf = static_cast(malloc(bytesPerRow)); + if (!rowBuf) { + LOG_ERR("PXC", "malloc failed for rowBuf (%d bytes, %dx%d)", bytesPerRow, c->width, c->height); + return; + } + + DirectPixelWriter pw; + pw.init(r); + + for (int row = 0; row < c->height; row++) { + if (c->file->read(rowBuf, bytesPerRow) != bytesPerRow) break; + pw.beginRow(row); + for (int col = 0; col < c->width; col++) { + const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; + pw.writePixel(pv); + } + } + free(rowBuf); + + GUI.drawButtonHints(const_cast(r), c->labels.btn1, c->labels.btn2, c->labels.btn3, c->labels.btn4); +} + +void pxcLoadingOverlay(const GfxRenderer& r, const void*) { + constexpr int margin = 15; + const char* msg = tr(STR_LOADING_POPUP); + const int y = static_cast(r.getScreenHeight() * 0.075f); + const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); + const int w = textWidth + margin * 2; + const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; + const int x = (r.getScreenWidth() - w) / 2; + r.fillRect(x - 2, y - 2, w + 4, h + 4, true); + r.fillRect(x, y, w, h, false); + r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); +} +} // namespace + +PxcViewerActivity::PxcViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path) + : Activity("PxcViewer", renderer, mappedInput), filePath(std::move(path)) {} + +void PxcViewerActivity::loadSiblingImages() { + siblingImages.clear(); + currentImageIndex = -1; + + if (filePath.empty()) return; + + std::string dirPath = FsHelpers::extractFolderPath(filePath); + size_t lastSlash = filePath.find_last_of('/'); + std::string fileName = (lastSlash != std::string::npos) ? filePath.substr(lastSlash + 1) : filePath; + + auto dir = Storage.open(dirPath.c_str()); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return; + } + + char name[500]; + for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { + if (!file.isDirectory()) { + file.getName(name, sizeof(name)); + if (name[0] != '.') { + std::string fname(name); + if (FsHelpers::hasPxcExtension(fname)) { + siblingImages.push_back(fname); + } + } + } + file.close(); + } + dir.close(); + + FsHelpers::sortFileList(siblingImages); + + for (size_t i = 0; i < siblingImages.size(); ++i) { + if (siblingImages[i] == fileName) { + currentImageIndex = static_cast(i); + break; + } + } +} + +void PxcViewerActivity::renderPxcToFramebuffer(FsFile& file, uint16_t width, uint16_t height, uint32_t dataOffset, + bool hasPrevious, bool hasNext) { + const auto labels = + mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), (hasPrevious ? "<" : ""), (hasNext ? ">" : "")); + PxcCtx ctx{&file, dataOffset, width, height, labels}; + renderer.renderGrayscaleSinglePass(GfxRenderer::GrayscaleDriveMode::FactoryQuality, &pxcRenderCallback, &ctx, + &pxcLoadingOverlay, nullptr); +} + +void PxcViewerActivity::onEnter() { + Activity::onEnter(); + + if (siblingImages.empty() && !filePath.empty()) { + loadSiblingImages(); + } + + const int screenWidth = renderer.getScreenWidth(); + const int screenHeight = renderer.getScreenHeight(); + FsFile file; + if (!Storage.openFileForRead("PXC", filePath, file)) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_10_FONT_ID, screenHeight / 2, "Could not open file"); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + return; + } + + uint16_t pxcWidth, pxcHeight; + if (file.read(&pxcWidth, 2) != 2 || file.read(&pxcHeight, 2) != 2) { + LOG_ERR("PXC", "Header read failed: %s", filePath.c_str()); + file.close(); + renderer.clearScreen(); + renderer.drawCenteredText(UI_10_FONT_ID, screenHeight / 2, "Invalid PXC file"); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + return; + } + + if (pxcWidth > screenWidth || pxcHeight > screenHeight) { + LOG_ERR("PXC", "PXC size %dx%d does not match screen %dx%d", pxcWidth, pxcHeight, screenWidth, screenHeight); + file.close(); + renderer.clearScreen(); + renderer.drawCenteredText(UI_10_FONT_ID, screenHeight / 2, "PXC size mismatch"); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + return; + } + + const uint32_t dataOffset = file.position(); + const bool hasPrevious = (siblingImages.size() > 1 && currentImageIndex > 0); + const bool hasNext = (siblingImages.size() > 1 && currentImageIndex != -1 && + currentImageIndex < static_cast(siblingImages.size()) - 1); + renderPxcToFramebuffer(file, pxcWidth, pxcHeight, dataOffset, hasPrevious, hasNext); + + file.close(); + + // Sync BW framebuffer state after factory-gray render so onExit's HALF_REFRESH + // does a correct differential (controller BW state = white, not stale gray planes). + renderer.clearScreen(); + renderer.cleanupGrayscaleWithFrameBuffer(); +} + +void PxcViewerActivity::renderGrayscaleImage() { + FsFile file; + if (!Storage.openFileForRead("PXC", filePath, file)) return; + + uint16_t pxcWidth, pxcHeight; + if (file.read(&pxcWidth, 2) != 2 || file.read(&pxcHeight, 2) != 2) { + file.close(); + return; + } + + const int screenWidth = renderer.getScreenWidth(); + const int screenHeight = renderer.getScreenHeight(); + if (pxcWidth > screenWidth || pxcHeight > screenHeight) { + file.close(); + return; + } + + const uint32_t dataOffset = file.position(); + renderPxcToFramebuffer(file, pxcWidth, pxcHeight, dataOffset, false, false); + + file.close(); +} + +void PxcViewerActivity::onScreenshotRequest() { + renderGrayscaleImage(); + renderer.clearScreen(); + renderer.cleanupGrayscaleWithFrameBuffer(); +} + +void PxcViewerActivity::onExit() { + Activity::onExit(); + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); +} + +void PxcViewerActivity::doSetSleepCover() { + GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + + bool success = false; + FsFile inFile, outFile; + if (Storage.openFileForRead("PXC", filePath, inFile)) { + if (Storage.openFileForWrite("PXC", "/sleep.pxc", outFile)) { + char buffer[2048]; + int bytesRead; + success = true; + while ((bytesRead = inFile.read(buffer, sizeof(buffer))) > 0) { + if (outFile.write(buffer, bytesRead) != bytesRead) { + success = false; + break; + } + } + outFile.close(); + } + inFile.close(); + } + + if (success) { + SETTINGS.sleepScreen = CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM; + SETTINGS.saveToFile(); + GUI.drawPopup(renderer, tr(STR_DONE)); + } else { + GUI.drawPopup(renderer, tr(STR_FAILED_LOWER)); + } + + delay(1000); + onEnter(); +} + +void PxcViewerActivity::loop() { + Activity::loop(); + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + activityManager.goToFileBrowser(filePath); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + doSetSleepCover(); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Left) || + mappedInput.wasReleased(MappedInputManager::Button::Up)) { + if (siblingImages.size() > 1 && currentImageIndex > 0) { + currentImageIndex--; + std::string dirPath = FsHelpers::extractFolderPath(filePath); + if (dirPath.back() != '/') dirPath += "/"; + filePath = dirPath + siblingImages[currentImageIndex]; + onEnter(); + } + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Right) || + mappedInput.wasReleased(MappedInputManager::Button::Down)) { + if (siblingImages.size() > 1 && currentImageIndex != -1 && + currentImageIndex < static_cast(siblingImages.size()) - 1) { + currentImageIndex++; + std::string dirPath = FsHelpers::extractFolderPath(filePath); + if (dirPath.back() != '/') dirPath += "/"; + filePath = dirPath + siblingImages[currentImageIndex]; + onEnter(); + } + return; + } +} diff --git a/src/activities/util/PxcViewerActivity.h b/src/activities/util/PxcViewerActivity.h new file mode 100644 index 0000000000..5df19fc509 --- /dev/null +++ b/src/activities/util/PxcViewerActivity.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include +#include +#include + +#include "../Activity.h" +#include "MappedInputManager.h" + +class PxcViewerActivity final : public Activity { + public: + PxcViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string filePath); + + void onEnter() override; + void onExit() override; + void loop() override; + void onScreenshotRequest() override; + + private: + std::string filePath; + std::vector siblingImages; + int currentImageIndex = -1; + + void loadSiblingImages(); + void doSetSleepCover(); + void renderGrayscaleImage(); + void renderPxcToFramebuffer(FsFile& file, uint16_t width, uint16_t height, uint32_t dataOffset, bool hasPrevious, + bool hasNext); +}; diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 012ad26b3c..32e5300326 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -86,7 +86,7 @@ UIIcon UITheme::getFileIcon(const std::string& filename) { if (FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename)) { return Text; } - if (FsHelpers::hasBmpExtension(filename)) { + if (FsHelpers::hasBmpExtension(filename) || FsHelpers::hasPxcExtension(filename)) { return Image; } return File; diff --git a/src/main.cpp b/src/main.cpp index a273534c0a..4fe44a0035 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -396,7 +396,18 @@ void loop() { screenshotButtonsReleased = false; { RenderLock lock; - ScreenshotUtil::takeScreenshot(renderer); + if (renderer.getDisplayState() == GfxRenderer::DisplayState::FactoryLut) { + // Display shows a grayscale image held only as physical particle positions. + // frameBuffer has been reset to white — do NOT use the BW screenshot path. + // Only arm the hook when an activity exists to call onScreenshotRequest(). + Activity* activity = activityManager.getCurrentActivity(); + if (activity) { + ScreenshotUtil::prepareFactoryLutScreenshot(renderer); + activity->onScreenshotRequest(); + } + } else { + ScreenshotUtil::takeScreenshot(renderer); + } } } return; diff --git a/src/util/ScreenshotUtil.cpp b/src/util/ScreenshotUtil.cpp index df8704f7ea..69501683b9 100644 --- a/src/util/ScreenshotUtil.cpp +++ b/src/util/ScreenshotUtil.cpp @@ -67,6 +67,11 @@ void ScreenshotUtil::buildFilename(const ScreenshotInfo& info, char* buf, size_t } } +// Static storage for the pending grayscale filename. Lifetime: set by prepareFactoryLutScreenshot, +// consumed (read) inside grayscaleHookCallback when the hook fires. The buffer is never freed — +// it's a fixed-size static so it persists for the lifetime of the firmware. +static char s_pendingFilename[64]; + void ScreenshotUtil::takeScreenshot(GfxRenderer& renderer) { const uint8_t* fb = renderer.getFrameBuffer(); if (!fb) { @@ -180,3 +185,170 @@ bool ScreenshotUtil::saveFramebufferAsBmp(const char* filename, const uint8_t* f return true; } + +// --------------------------------------------------------------------------- +// Grayscale (factory LUT) screenshot — hook-based two-plane capture +// --------------------------------------------------------------------------- + +void ScreenshotUtil::prepareFactoryLutScreenshot(GfxRenderer& renderer) { + snprintf(s_pendingFilename, sizeof(s_pendingFilename), "/screenshots/screenshot-%lu.bmp", millis()); + renderer.setScreenshotHook(grayscaleHookCallback, s_pendingFilename); +} + +void ScreenshotUtil::grayscaleHookCallback(const uint8_t* lsbPlane, const uint8_t* msbPlane, int physWidth, + int physHeight, void* ctx) { + const char* filename = static_cast(ctx); + if (saveGrayscaleBmp(filename, lsbPlane, msbPlane, physWidth, physHeight)) { + LOG_DBG("SCR", "Grayscale screenshot saved: %s", filename); + } else { + LOG_ERR("SCR", "Failed to save grayscale screenshot"); + } +} + +bool ScreenshotUtil::saveGrayscaleBmp(const char* filename, const uint8_t* lsbPlane, const uint8_t* msbPlane, + int physWidth, int physHeight) { + // Logical output after 90° CCW rotation (same orientation as BW screenshots): + // outWidth = physHeight (BMP columns = physical rows) + // outHeight = physWidth (BMP rows = physical columns) + const int outWidth = physHeight; + const int outHeight = physWidth; + + // Ensure /screenshots directory exists. + if (!Storage.exists("/screenshots")) { + if (!Storage.mkdir("/screenshots")) { + return false; + } + } + + FsFile file; + if (!Storage.openFileForWrite("SCR", filename, file)) { + LOG_ERR("SCR", "Failed to open grayscale screenshot file"); + return false; + } + + // --- BMP file header (14 bytes) + DIB header (40 bytes) --- + // 8-bit palette BMP: pixel data offset = 14 + 40 + 256*4 = 1078 bytes. + const uint32_t rowSizePadded = (static_cast(outWidth) + 3u) & ~3u; + const uint32_t imageSize = rowSizePadded * static_cast(outHeight); + const uint32_t fileSize = 1078u + imageSize; + +#pragma pack(push, 1) + struct Bmp8Header { + // File header + uint16_t bfType; + uint32_t bfSize; + uint16_t bfReserved1; + uint16_t bfReserved2; + uint32_t bfOffBits; + // DIB header (BITMAPINFOHEADER) + uint32_t biSize; + int32_t biWidth; + int32_t biHeight; + uint16_t biPlanes; + uint16_t biBitCount; + uint32_t biCompression; + uint32_t biSizeImage; + int32_t biXPelsPerMeter; + int32_t biYPelsPerMeter; + uint32_t biClrUsed; + uint32_t biClrImportant; + }; +#pragma pack(pop) + + Bmp8Header hdr; + memset(&hdr, 0, sizeof(hdr)); + hdr.bfType = 0x4D42u; + hdr.bfSize = fileSize; + hdr.bfReserved1 = 0u; + hdr.bfReserved2 = 0u; + hdr.bfOffBits = 1078u; + hdr.biSize = 40u; + hdr.biWidth = outWidth; + hdr.biHeight = outHeight; // positive = bottom-up row order + hdr.biPlanes = 1u; + hdr.biBitCount = 8u; + hdr.biCompression = 0u; // BI_RGB (uncompressed) + hdr.biSizeImage = imageSize; + hdr.biXPelsPerMeter = 2835; + hdr.biYPelsPerMeter = 2835; + hdr.biClrUsed = 256u; + hdr.biClrImportant = 0u; + + bool write_error = false; + if (file.write(reinterpret_cast(&hdr), sizeof(hdr)) != sizeof(hdr)) { + write_error = true; + } + + if (!write_error) { + // Palette: 256 entries × 4 bytes = 1024 bytes. + // GRAY2_LSB encoding: index = lsb | (msb << 1) + // 0 → White (0xFF) lsb=0, msb=0 + // 1 → LightGrey lsb=1, msb=0 + // 2 → DarkGrey lsb=0, msb=1 + // 3 → Black (0x00) lsb=1, msb=1 + static constexpr uint8_t kPalette[16] = { + 0xFF, 0xFF, 0xFF, 0x00, // index 0: White + 0xAA, 0xAA, 0xAA, 0x00, // index 1: LightGrey + 0x55, 0x55, 0x55, 0x00, // index 2: DarkGrey + 0x00, 0x00, 0x00, 0x00, // index 3: Black + }; + if (file.write(kPalette, sizeof(kPalette)) != sizeof(kPalette)) { + write_error = true; + } + if (!write_error) { + // Write 252 zero entries to complete the 256-entry palette table. + uint8_t zeros[32] = {}; + uint32_t remaining = 252u * 4u; + while (remaining > 0u && !write_error) { + const uint32_t chunk = (remaining > sizeof(zeros)) ? static_cast(sizeof(zeros)) : remaining; + if (file.write(zeros, chunk) != chunk) write_error = true; + remaining -= chunk; + } + } + } + + if (write_error) { + file.close(); + Storage.remove(filename); + return false; + } + + // --- Pixel data: one byte per pixel (palette index 0–3), bottom-to-top rows --- + // Row buffer: outWidth bytes padded to 4-byte boundary (> 256 bytes; heap-allocated). + uint8_t* rowBuf = static_cast(malloc(rowSizePadded)); + if (!rowBuf) { + LOG_ERR("SCR", "saveGrayscaleBmp: malloc failed for row buffer (%u bytes)", rowSizePadded); + file.close(); + Storage.remove(filename); + return false; + } + + // physWidth pixels per physical row; each byte holds 8 pixels (1-bit planes). + const int bytesPerPhysRow = physWidth / 8; + + for (int outY = 0; outY < outHeight && !write_error; outY++) { + memset(rowBuf, 0, rowSizePadded); + for (int outX = 0; outX < outWidth; outX++) { + // 90° CCW rotation (same transform as BW saveFramebufferAsBmp): + // BMP rows are bottom-to-top, so outY=0 is the bottom of the logical image. + const int srcX = physWidth - 1 - outY; + const int srcY = physHeight - 1 - outX; + const int byteIdx = srcY * bytesPerPhysRow + (srcX / 8); + const int bitPos = 7 - (srcX % 8); + const uint8_t lsb = (lsbPlane[byteIdx] >> bitPos) & 1u; + const uint8_t msb = (msbPlane[byteIdx] >> bitPos) & 1u; + rowBuf[outX] = lsb | static_cast(msb << 1); + } + if (file.write(rowBuf, rowSizePadded) != rowSizePadded) write_error = true; + } + + free(rowBuf); + file.close(); + + if (write_error) { + Storage.remove(filename); + return false; + } + + return true; +} diff --git a/src/util/ScreenshotUtil.h b/src/util/ScreenshotUtil.h index 995d301f93..c819049da4 100644 --- a/src/util/ScreenshotUtil.h +++ b/src/util/ScreenshotUtil.h @@ -10,6 +10,15 @@ class ScreenshotUtil { static void takeScreenshot(GfxRenderer& renderer); static bool saveFramebufferAsBmp(const char* filename, const uint8_t* framebuffer, int width, int height); + // Called when displayState == FactoryLut. Installs a one-shot hook on the renderer; + // the caller must then call currentActivity->onScreenshotRequest() to trigger the re-render + // that fires the hook and captures both grayscale planes. + static void prepareFactoryLutScreenshot(GfxRenderer& renderer); + private: static void buildFilename(const ScreenshotInfo& info, char* buf, size_t bufSize); + static void grayscaleHookCallback(const uint8_t* lsbPlane, const uint8_t* msbPlane, int physWidth, int physHeight, + void* ctx); + static bool saveGrayscaleBmp(const char* filename, const uint8_t* lsbPlane, const uint8_t* msbPlane, int physWidth, + int physHeight); };