diff --git a/lib/Epub/Epub/blocks/ImageBlock.cpp b/lib/Epub/Epub/blocks/ImageBlock.cpp index 1ecc2421..709d70e4 100644 --- a/lib/Epub/Epub/blocks/ImageBlock.cpp +++ b/lib/Epub/Epub/blocks/ImageBlock.cpp @@ -1,5 +1,6 @@ #include "ImageBlock.h" +#include #include #include #include @@ -55,10 +56,22 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight); - // Read and render row by row to minimize memory usage + // Read several rows per SD access. A full-page image is re-rendered on every + // grayscale strip pass (~14x per page), and a one-row-per-read loop here means + // cachedHeight (~728) tiny reads through the storage mutex + SdFat each time — + // the dominant cost of displaying an image page. Batching rows into a ~4KB + // buffer cuts that to ~20 reads per pass without holding the whole image. const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte - uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow); - if (!rowBuffer) { + int rowsPerRead = 4096 / bytesPerRow; + if (rowsPerRead < 1) rowsPerRead = 1; + if (rowsPerRead > cachedHeight) rowsPerRead = cachedHeight; + uint8_t* readBuffer = (uint8_t*)malloc((size_t)rowsPerRead * bytesPerRow); + if (!readBuffer) { + // Fall back to a single-row buffer under memory pressure. + rowsPerRead = 1; + readBuffer = (uint8_t*)malloc(bytesPerRow); + } + if (!readBuffer) { LOG_ERR("IMG", "Failed to allocate row buffer"); return false; } @@ -66,16 +79,31 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, DirectPixelWriter pw; pw.init(renderer); + int rowsInBuffer = 0; + int bufferRow = 0; for (int row = 0; row < cachedHeight; row++) { - if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) { - LOG_ERR("IMG", "Cache read error at row %d", row); - free(rowBuffer); - return false; + if (bufferRow >= rowsInBuffer) { + const int toRead = (cachedHeight - row < rowsPerRead) ? (cachedHeight - row) : rowsPerRead; + const size_t bytes = (size_t)toRead * bytesPerRow; + if (cacheFile.read(readBuffer, bytes) != static_cast(bytes)) { + LOG_ERR("IMG", "Cache read error at row %d", row); + free(readBuffer); + return false; + } + rowsInBuffer = toRead; + bufferRow = 0; } + const uint8_t* rowBuffer = readBuffer + (size_t)bufferRow * bytesPerRow; + bufferRow++; + const int destY = y + row; pw.beginRow(destY); - for (int col = 0; col < cachedWidth; col++) { + // On a grayscale strip pass only a narrow column window of the image is in + // the active band; skip the rest instead of unpacking+clipping every pixel. + int colStart, colEnd; + pw.bandColRange(x, cachedWidth, colStart, colEnd); + for (int col = colStart; col < colEnd; 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; @@ -84,7 +112,7 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, } } - free(rowBuffer); + free(readBuffer); LOG_DBG("IMG", "Cache render complete"); return true; } @@ -92,6 +120,14 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, } // namespace void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) { + // The font-prewarm scan pass only accumulates glyphs; an image contributes + // none, and its DirectPixelWriter output bypasses the renderer's scan-mode + // suppression, so it would otherwise do a full (discarded) cache render every + // page view. Skip it here. The image still draws in the real BW/grayscale + // passes; on first view this just moves the one-time decode to the BW pass. + FontCacheManager* fcm = renderer.getFontCacheManager(); + if (fcm && fcm->isScanning()) return; + LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height); const int screenWidth = renderer.getScreenWidth(); diff --git a/lib/Epub/Epub/converters/DirectPixelWriter.h b/lib/Epub/Epub/converters/DirectPixelWriter.h index 25cb5755..c5f6cd48 100644 --- a/lib/Epub/Epub/converters/DirectPixelWriter.h +++ b/lib/Epub/Epub/converters/DirectPixelWriter.h @@ -4,6 +4,8 @@ #include #include +#include + // 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, @@ -99,6 +101,46 @@ struct DirectPixelWriter { rowPhyYBase = phyYBase + logicalY * phyYStepY; } + // For the current row (set via beginRow), narrow [colStart, colEnd) to the + // columns whose pixels fall inside the active strip band. writePixel() would + // clip the rest anyway, but on a strip pass that is most of a full-page image + // (only ~one strip-height worth of columns survive in portrait); skipping them + // here avoids the per-pixel unpack+transform entirely. For full-frame passes + // (clipRows == panel height) the range is unchanged. xBase is the logical X of + // column 0; the band test mirrors writePixel(): 0 <= phyY - originY < clipRows. + inline void bandColRange(int xBase, int width, int& colStart, int& colEnd) const { + // init() only ever sets phyYStepX to 0, +1, or -1; the +1/-1 solve below + // relies on that. + assert(phyYStepX == 0 || phyYStepX == 1 || phyYStepX == -1); + colStart = 0; + colEnd = width; + if (phyYStepX == 0) { + // phyY is constant across the row: the whole row is in-band or out. + const int sy = rowPhyYBase - originY; + if (static_cast(sy) >= static_cast(clipRows)) colEnd = 0; + return; + } + // phyY = rowPhyYBase + logicalX * phyYStepX (phyYStepX is +1 or -1). + // Solve originY <= phyY <= originY + clipRows - 1 for logicalX. + const int loY = originY; + const int hiY = originY + clipRows - 1; + int xLo, xHi; + if (phyYStepX > 0) { + xLo = loY - rowPhyYBase; + xHi = hiY - rowPhyYBase; + } else { + xLo = rowPhyYBase - hiY; + xHi = rowPhyYBase - loY; + } + const int cs = xLo - xBase; + const int ce = xHi - xBase + 1; // exclusive + if (cs > colStart) colStart = cs; + if (ce < colEnd) colEnd = ce; + if (colStart < 0) colStart = 0; + if (colEnd > width) colEnd = width; + if (colStart > colEnd) colStart = colEnd; + } + // Write a single 2-bit dithered pixel value to the framebuffer. // Must be called after beginRow() for the current row. // No bounds checking — caller guarantees coordinates are valid. @@ -147,27 +189,42 @@ struct DirectPixelWriter { // Direct cache writer that eliminates per-pixel overhead from PixelCache::setPixel(). // Pre-computes row pointer so the inner loop is just byte index + bit manipulation. // -// Caller guarantees coordinates are within cache bounds. +// The cache buffer is a small streaming band (e.g. 16 rows), not the full image, +// so a band-relative row/column that lands outside it would corrupt adjacent +// heap. This writer therefore bounds-checks every access: beginRow() invalidates +// the row when it falls outside the band, and writePixel() drops out-of-range +// columns. This path only runs during the single decode that populates the +// cache, never on the screen render hot path, so the checks are cheap. struct DirectCacheWriter { uint8_t* buffer; int bytesPerRow; + int bandRows; int originX; - uint8_t* rowPtr; // Pre-computed for current row + uint8_t* rowPtr; // Pre-computed for current row; nullptr if row is out of band - void init(uint8_t* cacheBuffer, int cacheBytesPerRow, int cacheOriginX) { + void init(uint8_t* cacheBuffer, int cacheBytesPerRow, int cacheBandRows, int cacheOriginX) { buffer = cacheBuffer; bytesPerRow = cacheBytesPerRow; + bandRows = cacheBandRows; originX = cacheOriginX; rowPtr = nullptr; } - // Call once per row before the column loop. - inline void beginRow(int screenY, int cacheOriginY) { rowPtr = buffer + (screenY - cacheOriginY) * bytesPerRow; } + // Call once per row before the column loop. Drops rows outside the band. + inline void beginRow(int screenY, int cacheOriginY) { + const int localRow = screenY - cacheOriginY; + rowPtr = (static_cast(localRow) < static_cast(bandRows)) + ? buffer + (size_t)localRow * bytesPerRow + : nullptr; + } - // Write a 2-bit pixel value. No bounds checking. + // Write a 2-bit pixel value. Drops the write if the row is out of band or the + // column is out of range. inline void writePixel(int screenX, uint8_t value) const { + if (!rowPtr) return; const int localX = screenX - originX; - const int byteIdx = localX >> 2; // localX / 4 + const int byteIdx = localX >> 2; // localX / 4 + if (static_cast(byteIdx) >= static_cast(bytesPerRow)) return; const int bitShift = 6 - (localX & 3) * 2; // MSB first: pixel 0 at bits 6-7 rowPtr[byteIdx] = (rowPtr[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift); } diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp index 79810f33..b6b3bd97 100644 --- a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -132,7 +132,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { if (stride <= 0 || blockH <= 0 || validW <= 0) return 1; const bool useDithering = ctx->config->useDithering; - const bool caching = ctx->caching; + bool caching = ctx->caching; const int32_t fineScaleFPX = ctx->fineScaleFPX; const int32_t invScaleFPX = ctx->invScaleFPX; const int32_t fineScaleFPY = ctx->fineScaleFPY; @@ -169,9 +169,21 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { DirectPixelWriter pw; pw.init(renderer); + // The cache streams to disk one MCU-row band at a time. Flushing rows below + // this block (raster order guarantees they are final) repositions the band; + // cacheOriginY then maps screen rows to the band-local buffer rows. If a flush + // write fails, stop caching for the rest of this decode (and let finalize drop + // the partial file) rather than writing past the band buffer. DirectCacheWriter cw; + int cacheOriginY = 0; if (caching) { - cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.originX); + if (!ctx->cache.advanceTo(dstYStart)) { + caching = false; + ctx->caching = false; + } else { + cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.bandRows, ctx->cache.originX); + cacheOriginY = ctx->config->y + ctx->cache.bandStart; + } } // === 1:1 fast path: no scaling math === @@ -179,7 +191,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; pw.beginRow(outY); - if (caching) cw.beginRow(outY, ctx->config->y); + if (caching) cw.beginRow(outY, cacheOriginY); const uint8_t* row = &pixels[(dstY - blockY) * stride]; for (int dstX = dstXStart; dstX < dstXEnd; dstX++) { const int outX = cfgX + dstX; @@ -213,7 +225,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; pw.beginRow(outY); - if (caching) cw.beginRow(outY, ctx->config->y); + if (caching) cw.beginRow(outY, cacheOriginY); const int32_t srcFyFP = dstY * invScaleFPY; const int32_t fy = srcFyFP & FP_MASK; const int32_t fyInv = FP_ONE - fy; @@ -310,7 +322,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; pw.beginRow(outY); - if (caching) cw.beginRow(outY, ctx->config->y); + if (caching) cw.beginRow(outY, cacheOriginY); const int32_t srcFyFP = dstY * invScaleFPY; int ly = (srcFyFP >> FP_SHIFT) - blockY; if (ly < 0) ly = 0; @@ -469,11 +481,14 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat jpeg->setPixelType(EIGHT_BIT_GRAYSCALE); jpeg->setUserPointer(&ctx); - // Allocate cache buffer using final output dimensions + // Start streaming the pixel cache to disk. The band only needs to hold the + // tallest single decode block: a JPEGDEC MCU cell is at most 16 scaled-source + // rows tall, which our fine scale maps to this many output rows. ctx.caching = !config.cachePath.empty(); if (ctx.caching) { - if (!ctx.cache.allocate(destWidth, destHeight, config.x, config.y)) { - LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching"); + const int maxBlockDstRows = (int)(((int64_t)16 * ctx.fineScaleFPY) >> FP_SHIFT) + 2; + if (!ctx.cache.begin(config.cachePath, destWidth, destHeight, config.x, config.y, maxBlockDstRows)) { + LOG_ERR("JPG", "Failed to start cache stream, continuing without caching"); ctx.caching = false; } } @@ -484,14 +499,16 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat if (rc != 1) { LOG_ERR("JPG", "Decode failed (rc=%d, lastError=%d)", rc, jpeg->getLastError()); + if (ctx.caching) ctx.cache.abort(); return false; } LOG_DBG("JPG", "JPEG decoding complete - render time: %lu ms", decodeTime); - // Write cache file if caching was enabled + // Finalize the streamed cache file. Note: a flush failure mid-decode clears + // ctx.caching (the partial file is dropped), so re-read the flag here. if (ctx.caching) { - ctx.cache.writeToFile(config.cachePath); + ctx.cache.finalize(); } return true; diff --git a/lib/Epub/Epub/converters/PixelCache.h b/lib/Epub/Epub/converters/PixelCache.h index 10c88acf..354840d5 100644 --- a/lib/Epub/Epub/converters/PixelCache.h +++ b/lib/Epub/Epub/converters/PixelCache.h @@ -4,76 +4,181 @@ #include #include +#include #include #include -// Cache buffer for storing 2-bit pixels (4 levels) during decode. -// Packs 4 pixels per byte, MSB first. +// Streaming cache writer for 2-bit pixels (4 levels). Packs 4 pixels per byte, +// MSB first. +// +// The .pxc file is written incrementally in small row bands rather than holding +// the whole decoded image in one heap buffer. A full-page image (e.g. 482x728) +// needs ~88KB packed, which will not fit alongside the ~20KB JPEG decoder on a +// fragmented 380KB heap (free heap is routinely ~55KB on an image page). When +// the cache cannot be written, every render pass re-decodes the JPEG from +// scratch; an anti-aliased image page renders ~14 times (BW + AA restore + two +// grayscale planes x ~6 strips), so a 2s decode becomes a ~30s freeze / watchdog +// reset. Streaming keeps the working set to a single MCU-row band, so caching +// succeeds and the image is decoded exactly once. +// +// Correctness relies on JPEGDEC delivering blocks in raster MCU order (outer +// loop over y, inner over x: see jpeg.inl DecodeJPEG). Consecutive MCU rows map +// to contiguous, non-overlapping destination row ranges, so once a block whose +// top row is Y arrives, every output row < Y is final and is flushed to disk. struct PixelCache { - uint8_t* buffer; + uint8_t* buffer; // band buffer: (bandRows + 1) rows; last row kept zeroed + uint8_t* zeroRow; // points at the spare zeroed row, for gap/clip fill int width; int height; int bytesPerRow; - int originX; // config.x - to convert screen coords to cache coords - int originY; // config.y + int originX; // config.x - to convert screen coords to cache coords + int originY; // config.y + int bandRows; // rows held in the band buffer + int bandStart; // image-local row index of band buffer row 0 + int flushedRows; // image-local rows already written to file + HalFile file; + std::string cachePathStr; + bool ok; - PixelCache() : buffer(nullptr), width(0), height(0), bytesPerRow(0), originX(0), originY(0) {} + PixelCache() + : buffer(nullptr), + zeroRow(nullptr), + width(0), + height(0), + bytesPerRow(0), + originX(0), + originY(0), + bandRows(0), + bandStart(0), + flushedRows(0), + ok(false) {} PixelCache(const PixelCache&) = delete; PixelCache& operator=(const PixelCache&) = delete; - static constexpr size_t MAX_CACHE_BYTES = 256 * 1024; // 256KB limit for embedded targets + static constexpr int MIN_BAND_ROWS = 16; + static constexpr size_t MAX_BAND_BYTES = 24 * 1024; // band working-set ceiling - bool allocate(int w, int h, int ox, int oy) { + // Open the cache file, write the header, and allocate a band buffer big enough + // to hold the tallest single decode block (maxBlockDstRows output rows). + bool begin(const std::string& cachePath, int w, int h, int ox, int oy, int maxBlockDstRows) { width = w; height = h; originX = ox; originY = oy; bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte - size_t bufferSize = (size_t)bytesPerRow * h; - if (bufferSize > MAX_CACHE_BYTES) { - LOG_ERR("IMG", "Cache buffer too large: %d bytes for %dx%d (limit %d)", bufferSize, w, h, MAX_CACHE_BYTES); + bandStart = 0; + flushedRows = 0; + ok = false; + + int wantRows = maxBlockDstRows + 2; + if (wantRows < MIN_BAND_ROWS) wantRows = MIN_BAND_ROWS; + if (wantRows > h) wantRows = h; + + size_t maxRowsByMem = MAX_BAND_BYTES / (size_t)bytesPerRow; + if (maxRowsByMem < 1) maxRowsByMem = 1; + if ((size_t)wantRows > maxRowsByMem) wantRows = (int)maxRowsByMem; + + // A single decode block must fit inside the band, otherwise streaming would + // drop rows. This only fails for pathological upscales that could not be + // cached at all; fall back to the no-cache path. + if (wantRows < maxBlockDstRows) { + LOG_ERR("IMG", "Cache band too small (%d < %d rows) for %dx%d", wantRows, maxBlockDstRows, w, h); return false; } - buffer = (uint8_t*)malloc(bufferSize); - if (buffer) { - memset(buffer, 0, bufferSize); - LOG_DBG("IMG", "Allocated cache buffer: %d bytes for %dx%d", bufferSize, w, h); + bandRows = wantRows; + + const size_t bufSize = (size_t)(bandRows + 1) * bytesPerRow; // +1 spare zero row + buffer = (uint8_t*)malloc(bufSize); + if (!buffer) { + LOG_ERR("IMG", "OOM cache band: %u bytes", (unsigned)bufSize); + return false; } - return buffer != nullptr; - } + memset(buffer, 0, bufSize); + zeroRow = buffer + (size_t)bandRows * bytesPerRow; - void setPixel(int screenX, int screenY, uint8_t value) { - if (!buffer) return; - int localX = screenX - originX; - int localY = screenY - originY; - if (localX < 0 || localX >= width || localY < 0 || localY >= height) return; - - int byteIdx = localY * bytesPerRow + localX / 4; - int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7 - buffer[byteIdx] = (buffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift); - } - - bool writeToFile(const std::string& cachePath) { - if (!buffer) return false; - - HalFile cacheFile; - if (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) { + if (!Storage.openFileForWrite("IMG", cachePath, file)) { LOG_ERR("IMG", "Failed to open cache file for writing: %s", cachePath.c_str()); + free(buffer); + buffer = nullptr; + return false; + } + cachePathStr = cachePath; + + uint16_t w16 = (uint16_t)w; + uint16_t h16 = (uint16_t)h; + if (file.write(&w16, 2) != 2 || file.write(&h16, 2) != 2) { + LOG_ERR("IMG", "Failed to write cache header: %s", cachePath.c_str()); + abort(); return false; } - uint16_t w = width; - uint16_t h = height; - cacheFile.write(&w, 2); - cacheFile.write(&h, 2); - cacheFile.write(buffer, bytesPerRow * height); - cacheFile.close(); - - LOG_DBG("IMG", "Cache written: %s (%dx%d, %d bytes)", cachePath.c_str(), width, height, 4 + bytesPerRow * height); + LOG_DBG("IMG", "Cache stream started: %s (%dx%d, band %d rows)", cachePath.c_str(), w, h, bandRows); + ok = true; return true; } + // Flush every output row below newTopRow (they are final in raster order) and + // reposition the band to start at newTopRow. Returns false if a write failed, + // in which case the caller must stop caching for the rest of the decode. + bool advanceTo(int newTopRow) { + if (!ok) return false; + if (newTopRow <= bandStart) return true; + if (newTopRow > height) newTopRow = height; + + for (int r = bandStart; r < newTopRow; ++r) { + const int idx = r - bandStart; + const uint8_t* rowPtr = (idx < bandRows) ? (buffer + (size_t)idx * bytesPerRow) : zeroRow; + if (file.write(rowPtr, (size_t)bytesPerRow) != (size_t)bytesPerRow) { + LOG_ERR("IMG", "Cache write error at row %d", r); + ok = false; + return false; + } + } + flushedRows = newTopRow; + bandStart = newTopRow; + memset(buffer, 0, (size_t)bandRows * bytesPerRow); // fresh band (gaps stay black) + return true; + } + + // Flush the final band and zero-fill any rows never covered (image clipped by + // the screen), then close the file. + bool finalize() { + if (!ok) { + abort(); + return false; + } + for (int r = flushedRows; r < height; ++r) { + const int idx = r - bandStart; + const uint8_t* rowPtr = (idx >= 0 && idx < bandRows) ? (buffer + (size_t)idx * bytesPerRow) : zeroRow; + if (file.write(rowPtr, (size_t)bytesPerRow) != (size_t)bytesPerRow) { + LOG_ERR("IMG", "Cache write error at row %d", r); + abort(); + return false; + } + } + file.close(); + LOG_DBG("IMG", "Cache written: %s (%dx%d, %d bytes)", cachePathStr.c_str(), width, height, + 4 + bytesPerRow * height); + ok = false; // file handed off; nothing left to clean up + return true; + } + + // Drop a partial/failed cache so a later decode re-creates it cleanly. + void abort() { + if (file.isOpen()) file.close(); + if (!cachePathStr.empty()) { + Storage.remove(cachePathStr.c_str()); + } + ok = false; + } + ~PixelCache() { + if (file.isOpen()) { + // The file is still open, so neither finalize() nor abort() ran, or a + // mid-stream write failed (advanceTo() cleared ok but left the file open). + // Drop the partial cache so we leave no corrupt file behind. + abort(); + } if (buffer) { free(buffer); buffer = nullptr; diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp index 1ebe77ab..24492e59 100644 --- a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -201,10 +201,19 @@ int pngDrawCallback(PNGDRAW* pDraw) { pw.init(*ctx->renderer); pw.beginRow(outY); + // The cache streams to disk one row at a time. Flushing rows below this one + // (PNGdec delivers scanlines top to bottom) repositions the single-row band. + // A flush failure stops caching for the rest of the decode so we never write + // past the band buffer; finalize() then drops the partial file. DirectCacheWriter cw; if (caching) { - cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.originX); - cw.beginRow(outY, ctx->config->y); + if (!ctx->cache.advanceTo(dstY)) { + caching = false; + ctx->caching = false; + } else { + cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.bandRows, ctx->cache.originX); + cw.beginRow(outY, ctx->config->y + ctx->cache.bandStart); + } } int srcX = 0; @@ -348,19 +357,16 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath return false; } - // Allocate cache buffer using SCALED dimensions. - // PNG decode is fast enough (~135ms for 400x600) that caching provides minimal benefit - // for larger images, while the cache buffer competes with the 44KB PNG decoder for heap. - // Skip caching when the buffer would exceed the framebuffer size (48KB). - static constexpr size_t PNG_MAX_CACHE_BYTES = 48000; + // Stream the pixel cache to disk. PNGdec delivers source scanlines top to + // bottom and we emit at most one (downscaled) output row per callback, so the + // band only needs a single row. Streaming keeps the working set tiny, so + // unlike the old full-image buffer it neither competes with the ~44KB decoder + // nor forces larger images to skip caching - which previously meant a full + // re-decode on every one of an image page's ~14 render passes. ctx.caching = !config.cachePath.empty(); if (ctx.caching) { - size_t cacheSize = (size_t)((ctx.dstWidth + 3) / 4) * ctx.dstHeight; - if (cacheSize > PNG_MAX_CACHE_BYTES) { - LOG_DBG("PNG", "Skipping cache: %zu bytes exceeds PNG limit (%zu)", cacheSize, PNG_MAX_CACHE_BYTES); - ctx.caching = false; - } else if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) { - LOG_ERR("PNG", "Failed to allocate cache buffer, continuing without caching"); + if (!ctx.cache.begin(config.cachePath, ctx.dstWidth, ctx.dstHeight, config.x, config.y, 1)) { + LOG_ERR("PNG", "Failed to start cache stream, continuing without caching"); ctx.caching = false; } } @@ -374,14 +380,15 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath if (rc != PNG_SUCCESS) { LOG_ERR("PNG", "Decode failed: %d", rc); + if (ctx.caching) ctx.cache.abort(); return false; } LOG_DBG("PNG", "PNG decoding complete - render time: %lu ms", decodeTime); - // Write cache file if caching was enabled and buffer was allocated + // Finalize the streamed cache (caching may have been cleared on a flush error). if (ctx.caching) { - ctx.cache.writeToFile(config.cachePath); + ctx.cache.finalize(); } return true;