From 2b0d071217ffb97736adb65f54e21af482b7d040 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 21 Feb 2026 00:07:39 +0100 Subject: [PATCH 001/301] Use byte operations --- lib/GfxRenderer/GfxRenderer.cpp | 112 ++++++++++++++++++++++++++++++-- lib/GfxRenderer/GfxRenderer.h | 3 + 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index b385bc03..028845fd 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -3,6 +3,8 @@ #include #include +#include + const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const { if (fontData->groups != nullptr) { if (!fontDecompressor) { @@ -236,15 +238,34 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con if (y2 < y1) { std::swap(y1, y2); } - for (int y = y1; y <= y2; y++) { - drawPixel(x1, y, state); + // In Portrait/PortraitInverted a logical vertical line maps to a physical horizontal span. + switch (orientation) { + case Portrait: + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - x1, y1, y2, state); + return; + case PortraitInverted: + fillPhysicalHSpan(x1, HalDisplay::DISPLAY_WIDTH - 1 - y2, HalDisplay::DISPLAY_WIDTH - 1 - y1, state); + return; + default: + for (int y = y1; y <= y2; y++) drawPixel(x1, y, state); + return; } } else if (y1 == y2) { if (x2 < x1) { std::swap(x1, x2); } - for (int x = x1; x <= x2; x++) { - drawPixel(x, y1, state); + // In Landscape a logical horizontal line maps to a physical horizontal span. + switch (orientation) { + case LandscapeCounterClockwise: + fillPhysicalHSpan(y1, x1, x2, state); + return; + case LandscapeClockwise: + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - y1, HalDisplay::DISPLAY_WIDTH - 1 - x2, + HalDisplay::DISPLAY_WIDTH - 1 - x1, state); + return; + default: + for (int x = x1; x <= x2; x++) drawPixel(x, y1, state); + return; } } else { // Bresenham's line algorithm — integer arithmetic only @@ -373,9 +394,88 @@ void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, con } } +// Write a solid horizontal span directly into the physical framebuffer with byte-level operations. +// Handles partial left/right bytes and fills the aligned middle with memset. +// Bit layout: MSB-first (bit 7 = phyX=0, bit 0 = phyX=7); state=true clears bits (dark pixel). +void GfxRenderer::fillPhysicalHSpan(const int phyY, const int phyX_start, const int phyX_end, const bool state) const { + const int cX0 = std::max(phyX_start, 0); + const int cX1 = std::min(phyX_end, (int)HalDisplay::DISPLAY_WIDTH - 1); + if (cX0 > cX1 || phyY < 0 || phyY >= (int)HalDisplay::DISPLAY_HEIGHT) return; + + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int startByte = cX0 >> 3; + const int endByte = cX1 >> 3; + const int leftBits = cX0 & 7; // first bit index within startByte + const int rightBits = cX1 & 7; // last bit index within endByte + + if (startByte == endByte) { + // Both endpoints in the same byte + const uint8_t mask = (0xFF >> leftBits) & ~(0xFF >> (rightBits + 1)); + if (state) + row[startByte] &= ~mask; + else + row[startByte] |= mask; + return; + } + + // Left partial byte + if (leftBits != 0) { + const uint8_t mask = 0xFF >> leftBits; + if (state) + row[startByte] &= ~mask; + else + row[startByte] |= mask; + } + + // Full bytes in the middle + const int fullStart = (leftBits == 0) ? startByte : startByte + 1; + const int fullEnd = (rightBits == 7) ? endByte : endByte - 1; + if (fullStart <= fullEnd) { + memset(row + fullStart, state ? 0x00 : 0xFF, fullEnd - fullStart + 1); + } + + // Right partial byte + if (rightBits != 7) { + const uint8_t mask = ~(0xFF >> (rightBits + 1)); + if (state) + row[endByte] &= ~mask; + else + row[endByte] |= mask; + } +} + void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { - for (int fillY = y; fillY < y + height; fillY++) { - drawLine(x, fillY, x + width - 1, fillY, state); + if (width <= 0 || height <= 0) return; + + // For each orientation, one logical dimension maps to a constant physical row, allowing the + // perpendicular dimension to be written as a byte-level span — eliminating per-pixel overhead. + switch (orientation) { + case Portrait: + // Logical column x → physical row (479-x); logical y range → physical x span + for (int lx = x; lx < x + width; lx++) { + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - lx, y, y + height - 1, state); + } + return; + case PortraitInverted: + // Logical column x → physical row x; logical y range → physical x span (mirrored) + for (int lx = x; lx < x + width; lx++) { + fillPhysicalHSpan(lx, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), HalDisplay::DISPLAY_WIDTH - 1 - y, + state); + } + return; + case LandscapeCounterClockwise: + // Logical row y → physical row y; logical x range → physical x span + for (int ly = y; ly < y + height; ly++) { + fillPhysicalHSpan(ly, x, x + width - 1, state); + } + return; + case LandscapeClockwise: + // Logical row y → physical row (479-y); logical x range → physical x span (mirrored) + for (int ly = y; ly < y + height; ly++) { + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - ly, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1), + HalDisplay::DISPLAY_WIDTH - 1 - x, state); + } + return; } } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e2d05d03..618ffd91 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -45,6 +45,9 @@ class GfxRenderer { void drawPixelDither(int x, int y) const; template void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const; + // Write a solid horizontal span directly to the physical framebuffer using byte-level operations. + // phyY: physical row; phyX_start/phyX_end: inclusive physical column range; state: true=dark. + void fillPhysicalHSpan(int phyY, int phyX_start, int phyX_end, bool state) const; public: explicit GfxRenderer(HalDisplay& halDisplay) From 71269b73255aa13ebabf1c1afeb27204ba8be275 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 21 Feb 2026 00:41:55 +0100 Subject: [PATCH 002/301] Add dither too --- lib/GfxRenderer/GfxRenderer.cpp | 121 ++++++++++++++++++++++++-------- lib/GfxRenderer/GfxRenderer.h | 7 +- 2 files changed, 97 insertions(+), 31 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 028845fd..e567d911 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -394,10 +394,11 @@ void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, con } } -// Write a solid horizontal span directly into the physical framebuffer with byte-level operations. -// Handles partial left/right bytes and fills the aligned middle with memset. -// Bit layout: MSB-first (bit 7 = phyX=0, bit 0 = phyX=7); state=true clears bits (dark pixel). -void GfxRenderer::fillPhysicalHSpan(const int phyY, const int phyX_start, const int phyX_end, const bool state) const { +// Write a patterned horizontal span directly into the physical framebuffer with byte-level operations. +// patternByte is repeated across the full span; partial edge bytes are blended with existing content. +// Bit layout: MSB-first (bit 7 = phyX=0, bit 0 = phyX=7); 0 bits = dark pixel, 1 bits = white pixel. +void GfxRenderer::fillPhysicalHSpanByte(const int phyY, const int phyX_start, const int phyX_end, + const uint8_t patternByte) const { const int cX0 = std::max(phyX_start, 0); const int cX1 = std::min(phyX_end, (int)HalDisplay::DISPLAY_WIDTH - 1); if (cX0 > cX1 || phyY < 0 || phyY >= (int)HalDisplay::DISPLAY_HEIGHT) return; @@ -410,40 +411,36 @@ void GfxRenderer::fillPhysicalHSpan(const int phyY, const int phyX_start, const if (startByte == endByte) { // Both endpoints in the same byte - const uint8_t mask = (0xFF >> leftBits) & ~(0xFF >> (rightBits + 1)); - if (state) - row[startByte] &= ~mask; - else - row[startByte] |= mask; + const uint8_t fillMask = (0xFF >> leftBits) & ~(0xFF >> (rightBits + 1)); + row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask); return; } // Left partial byte if (leftBits != 0) { - const uint8_t mask = 0xFF >> leftBits; - if (state) - row[startByte] &= ~mask; - else - row[startByte] |= mask; + const uint8_t fillMask = 0xFF >> leftBits; + row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask); } // Full bytes in the middle const int fullStart = (leftBits == 0) ? startByte : startByte + 1; const int fullEnd = (rightBits == 7) ? endByte : endByte - 1; if (fullStart <= fullEnd) { - memset(row + fullStart, state ? 0x00 : 0xFF, fullEnd - fullStart + 1); + memset(row + fullStart, patternByte, fullEnd - fullStart + 1); } // Right partial byte if (rightBits != 7) { - const uint8_t mask = ~(0xFF >> (rightBits + 1)); - if (state) - row[endByte] &= ~mask; - else - row[endByte] |= mask; + const uint8_t fillMask = ~(0xFF >> (rightBits + 1)); + row[endByte] = (row[endByte] & ~fillMask) | (patternByte & fillMask); } } +// Thin wrapper: state=true → 0x00 (all dark), false → 0xFF (all white). +void GfxRenderer::fillPhysicalHSpan(const int phyY, const int phyX_start, const int phyX_end, const bool state) const { + fillPhysicalHSpanByte(phyY, phyX_start, phyX_end, state ? 0x00 : 0xFF); +} + void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { if (width <= 0 || height <= 0) return; @@ -512,17 +509,81 @@ void GfxRenderer::fillRectDither(const int x, const int y, const int width, cons fillRect(x, y, width, height, true); } else if (color == Color::White) { fillRect(x, y, width, height, false); - } else if (color == Color::LightGray) { - for (int fillY = y; fillY < y + height; fillY++) { - for (int fillX = x; fillX < x + width; fillX++) { - drawPixelDither(fillX, fillY); - } - } } else if (color == Color::DarkGray) { - for (int fillY = y; fillY < y + height; fillY++) { - for (int fillX = x; fillX < x + width; fillX++) { - drawPixelDither(fillX, fillY); - } + // Pattern: dark where (phyX + phyY) % 2 == 0 (alternating checkerboard). + // Byte patterns (phyY even / phyY odd): + // Portrait / PortraitInverted: 0xAA / 0x55 + // LandscapeCW / LandscapeCCW: 0x55 / 0xAA + switch (orientation) { + case Portrait: + for (int lx = x; lx < x + width; lx++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx; + const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55; + fillPhysicalHSpanByte(phyY, y, y + height - 1, pb); + } + return; + case PortraitInverted: + for (int lx = x; lx < x + width; lx++) { + const int phyY = lx; + const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55; + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), + HalDisplay::DISPLAY_WIDTH - 1 - y, pb); + } + return; + case LandscapeCounterClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = ly; + const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA; + fillPhysicalHSpanByte(phyY, x, x + width - 1, pb); + } + return; + case LandscapeClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly; + const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA; + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1), + HalDisplay::DISPLAY_WIDTH - 1 - x, pb); + } + return; + } + } else if (color == Color::LightGray) { + // Pattern: dark where phyX % 2 == 0 && phyY % 2 == 0 (1-in-4 pixels dark). + // Byte patterns (phyY even / phyY odd) — 0xFF rows write no dark pixels and are skipped: + // Portrait: 0xFF (skip) / 0x55 + // PortraitInverted: 0xAA / 0xFF (skip) + // LandscapeCCW: 0x55 / 0xFF (skip) + // LandscapeCW: 0xFF (skip) / 0xAA + switch (orientation) { + case Portrait: + for (int lx = x; lx < x + width; lx++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx; + if (phyY % 2 == 0) continue; // all-white row — no dark pixels to write + fillPhysicalHSpanByte(phyY, y, y + height - 1, 0x55); + } + return; + case PortraitInverted: + for (int lx = x; lx < x + width; lx++) { + const int phyY = lx; + if (phyY % 2 != 0) continue; // all-white row + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), + HalDisplay::DISPLAY_WIDTH - 1 - y, 0xAA); + } + return; + case LandscapeCounterClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = ly; + if (phyY % 2 != 0) continue; // all-white row + fillPhysicalHSpanByte(phyY, x, x + width - 1, 0x55); + } + return; + case LandscapeClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly; + if (phyY % 2 == 0) continue; // all-white row + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1), + HalDisplay::DISPLAY_WIDTH - 1 - x, 0xAA); + } + return; } } } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 618ffd91..e936a613 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -45,8 +45,13 @@ class GfxRenderer { void drawPixelDither(int x, int y) const; template void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const; + // Write a patterned horizontal span directly to the physical framebuffer using byte-level operations. + // phyY: physical row; phyX_start/phyX_end: inclusive physical column range. + // patternByte is repeated across the span; partial edge bytes are blended with existing content. + // Bit layout: MSB-first (bit 7 = phyX=0); 0 bits = dark pixel, 1 bits = white pixel. + void fillPhysicalHSpanByte(int phyY, int phyX_start, int phyX_end, uint8_t patternByte) const; // Write a solid horizontal span directly to the physical framebuffer using byte-level operations. - // phyY: physical row; phyX_start/phyX_end: inclusive physical column range; state: true=dark. + // Thin wrapper around fillPhysicalHSpanByte: state=true → 0x00 (dark), false → 0xFF (white). void fillPhysicalHSpan(int phyY, int phyX_start, int phyX_end, bool state) const; public: From e87106d4d4d6340cd047d6d1650129f945bbbfcc Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 21 Feb 2026 10:42:11 +0100 Subject: [PATCH 003/301] Optimize 1bit font rendering --- lib/GfxRenderer/GfxRenderer.cpp | 300 ++++++++++++++++++++++++++++++++ lib/GfxRenderer/GfxRenderer.h | 5 + 2 files changed, 305 insertions(+) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index b385bc03..16b71426 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -61,6 +61,250 @@ static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, enum class TextRotation { None, Rotated90CW }; +// ============================================================================= +// Fast-path glyph rendering helpers (1-bit BW fonts, TextRotation::None) +// ============================================================================= +// +// OVERVIEW +// -------- +// The legacy path called drawPixel() once per set glyph pixel. drawPixel() +// invokes rotateCoordinates() (a switch), does a bounds check, logs on OOB, +// then writes one bit. For a typical 10×14 UI glyph that is ~100 calls. +// +// This fast path eliminates drawPixel() entirely by writing directly to the +// framebuffer in up to 8-pixel chunks via writeRowBits(). +// +// FRAMEBUFFER LAYOUT +// ------------------ +// 1 bpp, MSB-first, DISPLAY_WIDTH (800) pixels per row stored in +// DISPLAY_WIDTH_BYTES (100) bytes. Bit 7 of byte 0 = leftmost pixel of +// row 0. "Physical row" phyY occupies bytes [phyY*100 .. phyY*100+99]. +// A set bit (1) is WHITE; a cleared bit (0) is BLACK. +// +// LANDSCAPE ORIENTATIONS (2.5–3.1× speedup vs legacy) +// ------------------------------------------------------- +// phyX and phyY are both linear functions of glyphX/glyphY in these modes, +// so each glyph row maps directly to a physical framebuffer row. +// +// LandscapeCounterClockwise: phyX = screenXBase+glyphX, phyY = screenYBase+glyphY +// LandscapeClockwise: phyX = W-1-screenXBase-glyphX, phyY = H-1-screenYBase-glyphY +// +// Strategy: outer loop over glyphY (one physical row per iteration), inner +// loop reads 8-pixel chunks of that glyph row with bitmapExtract() and writes +// them with writeRowBits(). Bitmap access is purely sequential — fastest. +// LandscapeClockwise iterates glyph chunks right-to-left and applies +// reverseBits8() to flip horizontal direction. +// +// PORTRAIT ORIENTATIONS (~2× speedup vs legacy) +// ----------------------------------------------- +// Portrait (90° CW panel rotation): +// phyX = screenYBase+glyphY, phyY = H-1-screenXBase-glyphX +// PortraitInverted (90° CCW panel rotation): +// phyX = W-1-screenYBase-glyphY, phyY = screenXBase+glyphX +// +// Here glyph COLUMNS map to physical rows. Naively iterating column-by-column +// reads the bitmap with stride glyphWidth — cache-unfriendly and one bit at a +// time. Instead we use an 8×8 bit-matrix transpose: +// +// For each 8-row × 8-column glyph block: +// 1. Read 8 consecutive glyph rows (sequential bitmap access) into the +// top 8 bytes of a uint64_t (one bitmapExtract per row). +// 2. Call transpose8x8() — an O(log 8) butterfly transform — to swap +// the role of rows and columns in 3 passes of XOR-masking. +// 3. The resulting uint64_t holds 8 column bytes: byte k contains the +// bits for glyph column glyphX+k, one per physical row, MSB-aligned. +// 4. Write each column byte with writeRowBits() to its physical row. +// +// For PortraitInverted the glyph rows are packed in reverse order (last row +// at MSB of the uint64_t) before transposing. This ensures the post-transpose +// column bytes are already correctly ordered (MSB = leftmost phyX) without any +// per-column bit-reversal step. +// +// PARAMETERS +// ---------- +// screenXBase = cursorX + glyph->left (logical X of glyph pixel [0,0]) +// screenYBase = cursorY - glyph->top (logical Y of glyph pixel [0,0]) + +// Reverse all 8 bits of a byte (bit 7 ↔ bit 0). +static inline uint8_t reverseBits8(uint8_t b) { + b = (b & 0xF0) >> 4 | (b & 0x0F) << 4; + b = (b & 0xCC) >> 2 | (b & 0x33) << 2; + b = (b & 0xAA) >> 1 | (b & 0x55) << 1; + return b; +} + +// Transpose an 8×8 bit matrix packed into a uint64_t. +// +// Input layout (row-major, row 0 at MSB): +// bit (63 - 8*r - c) = matrix[r][c] (r=row 0..7, c=col 0..7) +// +// After transposition: +// bit (63 - 8*c - r) = matrix[r][c] +// i.e. byte k = bits [63-8k .. 56-8k] holds column k, MSB = row 0. +// +// Uses the classic 3-pass butterfly (Warren, "Hacker's Delight" §7-3): +// pass 1 swaps adjacent bit-pairs across a stride of 7 (nibble level), +// pass 2 swaps across stride 14 (byte level), +// pass 3 swaps across stride 28 (half-word level). +static inline uint64_t transpose8x8(uint64_t x) { + uint64_t t; + t = (x ^ (x >> 7)) & 0x00AA00AA00AA00AAULL; + x ^= t ^ (t << 7); + t = (x ^ (x >> 14)) & 0x0000CCCC0000CCCCULL; + x ^= t ^ (t << 14); + t = (x ^ (x >> 28)) & 0x00000000F0F0F0F0ULL; + x ^= t ^ (t << 28); + return x; +} + +// Extract up to 8 bits from a 1-bit MSB-first packed bitmap starting at bit +// position 'bitPos'. Returns them MSB-aligned (bit 7 = first extracted bit); +// the lower (8-count) bits are zeroed. +// All 'count' bits must lie within the valid bitmap byte range. +static inline uint8_t bitmapExtract(const uint8_t* bitmap, const int bitPos, const int count) { + const int byteIdx = bitPos >> 3; + const int bitOff = bitPos & 7; + uint8_t result; + if (bitOff == 0) { + result = bitmap[byteIdx]; + } else if (count <= 8 - bitOff) { + result = bitmap[byteIdx] << bitOff; // all bits inside first byte + } else { + result = (uint8_t)(((uint16_t)bitmap[byteIdx] << 8 | bitmap[byteIdx + 1]) >> (8 - bitOff)); + } + if (count < 8) result &= static_cast(0xFF << (8 - count)); + return result; +} + +// Write up to 8 foreground bits into a physical framebuffer row. +// bits — MSB-aligned; bit 7 = pixel at phyBitPos, lower (8-count) bits are zero. +// phyBitPos — physical X of the MSB pixel; must be in [0, DISPLAY_WIDTH). +// pixelState true → black (clear bits to 0), false → white (set bits to 1). +static inline void writeRowBits(uint8_t* const row, const int phyBitPos, const uint8_t bits, const bool pixelState) { + const int byteIdx = phyBitPos >> 3; + const int shift = phyBitPos & 7; + if (pixelState) { + row[byteIdx] &= ~(bits >> shift); + if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES) row[byteIdx + 1] &= ~(uint8_t)(bits << (8 - shift)); + } else { + row[byteIdx] |= (bits >> shift); + if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES) row[byteIdx + 1] |= (uint8_t)(bits << (8 - shift)); + } +} + +static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, + const int glyphHeight, const int screenXBase, const int screenYBase, + const bool pixelState, const GfxRenderer::Orientation orientation) { + switch (orientation) { + case GfxRenderer::LandscapeCounterClockwise: { + // phyX = screenXBase+glyphX, phyY = screenYBase+glyphY (identity mapping) + // Each glyph row is a contiguous physical h-span — read and write 8 px at a time. + for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { + const int phyY = screenYBase + glyphY; + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int rowBitStart = glyphY * glyphWidth; + for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { + const int count = std::min(8, glyphWidth - glyphX); + const uint8_t gbyte = bitmapExtract(bitmap, rowBitStart + glyphX, count); + if (gbyte == 0) continue; + const int phyBitPos = screenXBase + glyphX; + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, gbyte, pixelState); + } + } + break; + } + + case GfxRenderer::LandscapeClockwise: { + // phyX = W-1-screenXBase-glyphX, phyY = H-1-screenYBase-glyphY (180° flip) + // glyphX=0 is rightmost; iterate glyph row right-to-left in 8-px chunks so each + // chunk writes a contiguous left-to-right physical h-span after bit-reversal. + for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenYBase + glyphY); + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int rowBitStart = glyphY * glyphWidth; + for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) { + const int chunkStart = std::max(0, chunkEnd - 7); + const int count = chunkEnd - chunkStart + 1; + // Read chunk in glyph (left-to-right) order then reverse bits so MSB maps to + // glyphX=chunkEnd, which is the leftmost physical pixel of this chunk. + const uint8_t gbyte_fwd = bitmapExtract(bitmap, rowBitStart + chunkStart, count); + const uint8_t gbyte = reverseBits8(gbyte_fwd >> (8 - count)); + if (gbyte == 0) continue; + const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenXBase - chunkEnd; + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, gbyte, pixelState); + } + } + break; + } + + case GfxRenderer::Portrait: { + // phyX = screenYBase+glyphY, phyY = H-1-screenXBase-glyphX (90° CW) + // A glyph column maps to a physical row. Process in 8-row × 8-col blocks: + // pack 8 glyph rows (sequential reads) into uint64_t → transpose8x8 → + // each output byte is one glyph column's bits, MSB = row 0 = smallest phyX. + for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { + const int rowCount = std::min(8, glyphHeight - glyphY); + const int phyBitPos = screenYBase + glyphY; // leftmost phyX of this row-chunk + if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { + const int colCount = std::min(8, glyphWidth - glyphX); + uint64_t pack = 0; + int bitStart = glyphY * glyphWidth + glyphX; + for (int n = 0; n < rowCount; n++, bitStart += glyphWidth) { + pack |= static_cast(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * n); + } + pack = transpose8x8(pack); + // Byte k of pack = column (glyphX+k) bits, MSB = row 0 = leftmost phyX. + for (int k = 0; k < colCount; k++) { + const uint8_t cols_k = static_cast(pack >> (56 - 8 * k)); + if (cols_k == 0) continue; + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenXBase + glyphX + k); + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + writeRowBits(frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES, phyBitPos, cols_k, pixelState); + } + } + } + break; + } + + case GfxRenderer::PortraitInverted: { + // phyX = W-1-screenYBase-glyphY, phyY = screenXBase+glyphX (90° CCW) + // Like Portrait but glyphY=0 is the rightmost physical pixel. Pack rows in + // reverse order (last row at uint64_t MSB) so the transposed column bytes already + // have MSB = last row = leftmost phyX — no bit-reversal step needed. + for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { + const int rowCount = std::min(8, glyphHeight - glyphY); + // Leftmost phyX = W-1-screenYBase-(glyphY+rowCount-1). + const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenYBase - (glyphY + rowCount - 1); + if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { + const int colCount = std::min(8, glyphWidth - glyphX); + // Pack row (rowCount-1) at MSB down to row 0 at the lowest active byte. + uint64_t pack = 0; + int bitStart = glyphY * glyphWidth + glyphX; + for (int n = 0; n < rowCount; n++, bitStart += glyphWidth) { + pack |= static_cast(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * (rowCount - 1 - n)); + } + pack = transpose8x8(pack); + // Byte k = column (glyphX+k) bits, MSB = last row = leftmost phyX. + for (int k = 0; k < colCount; k++) { + const uint8_t cols_k = static_cast(pack >> (56 - 8 * k)); + if (cols_k == 0) continue; + const int phyY = screenXBase + glyphX + k; + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + writeRowBits(frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES, phyBitPos, cols_k, pixelState); + } + } + } + break; + } + } +} + // Shared glyph rendering logic for normal and rotated text. // Coordinate mapping and cursor advance direction are selected at compile time via the template parameter. template @@ -133,6 +377,16 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode } } } else { + // Fast path: 1-bit BW mode, non-rotated text — byte-level framebuffer writes, no drawPixel() per pixel. + if constexpr (rotation == TextRotation::None) { + if (renderMode == GfxRenderer::BW) { + renderGlyphFastBW(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState, + renderer.getOrientation()); + *cursorX += glyph->advanceX; + return; + } + } + // Fallback: rotated text or non-BW render mode — per-pixel drawPixel(). int pixelPosition = 0; for (int glyphY = 0; glyphY < height; glyphY++) { const int outerCoord = outerBase + glyphY; @@ -231,6 +485,52 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha } } +#ifdef ENABLE_RENDERCHAR_BENCHMARK +// Legacy per-pixel rendering path — mirrors the old renderCharImpl 1-bit BW loop. +// Used only by the renderChar benchmark to establish the baseline. +void GfxRenderer::drawTextBWLegacy(const int fontId, const int x, const int y, const char* text) const { + if (text == nullptr || *text == '\0') return; + const auto fontIt = fontMap.find(fontId); + if (fontIt == fontMap.end()) return; + const auto& fontFamily = fontIt->second; + + int yPos = y + getFontAscenderSize(fontId); + int xPos = x; + uint32_t cp; + while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + const EpdGlyph* glyph = fontFamily.getGlyph(cp, EpdFontFamily::REGULAR); + if (!glyph) glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, EpdFontFamily::REGULAR); + if (!glyph) continue; + const EpdFontData* fontData = fontFamily.getData(EpdFontFamily::REGULAR); + if (fontData->is2Bit) { + xPos += glyph->advanceX; + continue; + } + const uint8_t* bitmap = getGlyphBitmap(fontData, glyph); + if (bitmap != nullptr) { + const int screenYBase = yPos - glyph->top; + const int screenXBase = xPos + glyph->left; + int pixelPosition = 0; + for (int glyphY = 0; glyphY < glyph->height; glyphY++) { + for (int glyphX = 0; glyphX < glyph->width; glyphX++, pixelPosition++) { + const uint8_t bit = (bitmap[pixelPosition >> 3] >> (7 - (pixelPosition & 7))) & 1; + if (!bit) continue; + // Inline drawPixel without OOB logging — mirrors the old per-pixel path but clips silently, + // matching the fast path's behaviour so the benchmark measures rendering cost only. + int phyX, phyY; + rotateCoordinates(orientation, screenXBase + glyphX, screenYBase + glyphY, &phyX, &phyY); + if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX / 8); + const uint8_t bitPosition = 7 - (phyX % 8); + frameBuffer[byteIndex] &= ~(1 << bitPosition); // black pixel + } + } + } + xPos += glyph->advanceX; + } +} +#endif // ENABLE_RENDERCHAR_BENCHMARK + void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const { if (x1 == x2) { if (y2 < y1) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e2d05d03..f774d9c7 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -137,4 +137,9 @@ class GfxRenderer { // Low level functions uint8_t* getFrameBuffer() const; static size_t getBufferSize(); + +#ifdef ENABLE_RENDERCHAR_BENCHMARK + // Legacy (per-pixel drawPixel) text rendering — used only by the renderChar benchmark. + void drawTextBWLegacy(int fontId, int x, int y, const char* text) const; +#endif }; From 707782b6b525216bbf0afb48e27e1e2fce55bf0f Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 21 Feb 2026 11:03:32 +0100 Subject: [PATCH 004/301] Fix clipping issue --- lib/GfxRenderer/GfxRenderer.cpp | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 16b71426..1f839477 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -178,17 +178,33 @@ static inline uint8_t bitmapExtract(const uint8_t* bitmap, const int bitPos, con // Write up to 8 foreground bits into a physical framebuffer row. // bits — MSB-aligned; bit 7 = pixel at phyBitPos, lower (8-count) bits are zero. -// phyBitPos — physical X of the MSB pixel; must be in [0, DISPLAY_WIDTH). +// phyBitPos — physical X of the MSB pixel; may be negative for left-edge partial chunks. // pixelState true → black (clear bits to 0), false → white (set bits to 1). static inline void writeRowBits(uint8_t* const row, const int phyBitPos, const uint8_t bits, const bool pixelState) { - const int byteIdx = phyBitPos >> 3; - const int shift = phyBitPos & 7; - if (pixelState) { - row[byteIdx] &= ~(bits >> shift); - if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES) row[byteIdx + 1] &= ~(uint8_t)(bits << (8 - shift)); + uint8_t effectiveBits = bits; + int byteIdx; + int shift; + if (phyBitPos < 0) { + // Chunk starts off-screen left: clip by shifting out the off-screen MSBs. + // bits is MSB-aligned, so (bits << neg) discards the neg off-screen pixels + // and leaves the on-screen pixels MSB-aligned starting at physical X=0. + const int neg = -phyBitPos; + if (neg >= 8) return; // entire chunk is off-screen left + effectiveBits = bits << neg; + byteIdx = 0; + shift = 0; } else { - row[byteIdx] |= (bits >> shift); - if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES) row[byteIdx + 1] |= (uint8_t)(bits << (8 - shift)); + byteIdx = phyBitPos >> 3; + shift = phyBitPos & 7; + } + if (pixelState) { + row[byteIdx] &= ~(effectiveBits >> shift); + if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES) + row[byteIdx + 1] &= ~(uint8_t)(effectiveBits << (8 - shift)); + } else { + row[byteIdx] |= (effectiveBits >> shift); + if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES) + row[byteIdx + 1] |= (uint8_t)(effectiveBits << (8 - shift)); } } From fc3b28e94910de4f3cd2c2f4a0075e6077712a7f Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 21 Feb 2026 12:35:53 +0100 Subject: [PATCH 005/301] Deal with reader fonts (about 8-10% faster) --- lib/GfxRenderer/GfxRenderer.cpp | 152 +++++++++++++++++++++++++++----- 1 file changed, 132 insertions(+), 20 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 1f839477..8a20e7de 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -213,8 +213,6 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b const bool pixelState, const GfxRenderer::Orientation orientation) { switch (orientation) { case GfxRenderer::LandscapeCounterClockwise: { - // phyX = screenXBase+glyphX, phyY = screenYBase+glyphY (identity mapping) - // Each glyph row is a contiguous physical h-span — read and write 8 px at a time. for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { const int phyY = screenYBase + glyphY; if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; @@ -233,9 +231,6 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b } case GfxRenderer::LandscapeClockwise: { - // phyX = W-1-screenXBase-glyphX, phyY = H-1-screenYBase-glyphY (180° flip) - // glyphX=0 is rightmost; iterate glyph row right-to-left in 8-px chunks so each - // chunk writes a contiguous left-to-right physical h-span after bit-reversal. for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenYBase + glyphY); if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; @@ -244,8 +239,6 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) { const int chunkStart = std::max(0, chunkEnd - 7); const int count = chunkEnd - chunkStart + 1; - // Read chunk in glyph (left-to-right) order then reverse bits so MSB maps to - // glyphX=chunkEnd, which is the leftmost physical pixel of this chunk. const uint8_t gbyte_fwd = bitmapExtract(bitmap, rowBitStart + chunkStart, count); const uint8_t gbyte = reverseBits8(gbyte_fwd >> (8 - count)); if (gbyte == 0) continue; @@ -258,13 +251,9 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b } case GfxRenderer::Portrait: { - // phyX = screenYBase+glyphY, phyY = H-1-screenXBase-glyphX (90° CW) - // A glyph column maps to a physical row. Process in 8-row × 8-col blocks: - // pack 8 glyph rows (sequential reads) into uint64_t → transpose8x8 → - // each output byte is one glyph column's bits, MSB = row 0 = smallest phyX. for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { const int rowCount = std::min(8, glyphHeight - glyphY); - const int phyBitPos = screenYBase + glyphY; // leftmost phyX of this row-chunk + const int phyBitPos = screenYBase + glyphY; if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { const int colCount = std::min(8, glyphWidth - glyphX); @@ -274,7 +263,6 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b pack |= static_cast(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * n); } pack = transpose8x8(pack); - // Byte k of pack = column (glyphX+k) bits, MSB = row 0 = leftmost phyX. for (int k = 0; k < colCount; k++) { const uint8_t cols_k = static_cast(pack >> (56 - 8 * k)); if (cols_k == 0) continue; @@ -288,25 +276,18 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b } case GfxRenderer::PortraitInverted: { - // phyX = W-1-screenYBase-glyphY, phyY = screenXBase+glyphX (90° CCW) - // Like Portrait but glyphY=0 is the rightmost physical pixel. Pack rows in - // reverse order (last row at uint64_t MSB) so the transposed column bytes already - // have MSB = last row = leftmost phyX — no bit-reversal step needed. for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { const int rowCount = std::min(8, glyphHeight - glyphY); - // Leftmost phyX = W-1-screenYBase-(glyphY+rowCount-1). const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenYBase - (glyphY + rowCount - 1); if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { const int colCount = std::min(8, glyphWidth - glyphX); - // Pack row (rowCount-1) at MSB down to row 0 at the lowest active byte. uint64_t pack = 0; int bitStart = glyphY * glyphWidth + glyphX; for (int n = 0; n < rowCount; n++, bitStart += glyphWidth) { pack |= static_cast(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * (rowCount - 1 - n)); } pack = transpose8x8(pack); - // Byte k = column (glyphX+k) bits, MSB = last row = leftmost phyX. for (int k = 0; k < colCount; k++) { const uint8_t cols_k = static_cast(pack >> (56 - 8 * k)); if (cols_k == 0) continue; @@ -321,6 +302,128 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b } } +static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int rowStartPixel, + const int glyphXStartOrEnd, const int count, + const bool reverseXInChunk, const GfxRenderer::RenderMode renderMode) { + // drawMask uses raw 2-bit glyph values directly from font bitmaps: + // raw 0=white, 1=light gray, 2=dark gray, 3=black. + // Bit N set means: draw/update when raw==N. + // This avoids per-pixel remap (bmpVal = 3 - raw) and branch chains in the hot loop. + const uint8_t drawMask = + (renderMode == GfxRenderer::BW) ? 0x0E : ((renderMode == GfxRenderer::GRAYSCALE_MSB) ? 0x06 : 0x04); + + uint8_t mask = 0; + for (int i = 0; i < count; i++) { + const int logicalX = reverseXInChunk ? (glyphXStartOrEnd - i) : (glyphXStartOrEnd + i); + const int pixelPosition = rowStartPixel + logicalX; + const uint8_t byte = bitmap[pixelPosition >> 2]; + const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2; + const uint8_t raw = static_cast((byte >> bit_index) & 0x3); + if ((drawMask >> raw) & 0x01) mask |= static_cast(1u << (7 - i)); + } + return mask; +} + +static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, + const int glyphHeight, const int screenXBase, const int screenYBase, + const bool pixelState, const GfxRenderer::Orientation orientation, + const GfxRenderer::RenderMode renderMode) { + // Non-rotated text fast path for 2-bit glyphs. Writes compact masks directly to framebuffer rows. + // TextRotation::Rotated90CW keeps the legacy per-pixel fallback path for safety and readability. + const bool writeState = (renderMode == GfxRenderer::BW) ? pixelState : false; + const uint8_t drawMask = + (renderMode == GfxRenderer::BW) ? 0x0E : ((renderMode == GfxRenderer::GRAYSCALE_MSB) ? 0x06 : 0x04); + + switch (orientation) { + case GfxRenderer::LandscapeCounterClockwise: { + for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { + const int phyY = screenYBase + glyphY; + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int rowStartPixel = glyphY * glyphWidth; + for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { + const int count = std::min(8, glyphWidth - glyphX); + const uint8_t mask = build2BitRowMask(bitmap, rowStartPixel, glyphX, count, false, renderMode); + if (mask == 0) continue; + const int phyBitPos = screenXBase + glyphX; + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, mask, writeState); + } + } + break; + } + + case GfxRenderer::LandscapeClockwise: { + for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenYBase + glyphY); + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int rowStartPixel = glyphY * glyphWidth; + for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) { + const int chunkStart = std::max(0, chunkEnd - 7); + const int count = chunkEnd - chunkStart + 1; + const uint8_t mask = build2BitRowMask(bitmap, rowStartPixel, chunkEnd, count, true, renderMode); + if (mask == 0) continue; + const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenXBase - chunkEnd; + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, mask, writeState); + } + } + break; + } + + case GfxRenderer::Portrait: { + for (int glyphX = 0; glyphX < glyphWidth; glyphX++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenXBase + glyphX); + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { + const int count = std::min(8, glyphHeight - glyphY); + uint8_t mask = 0; + for (int i = 0; i < count; i++) { + const int logicalY = glyphY + i; + const int pixelPosition = logicalY * glyphWidth + glyphX; + const uint8_t byte = bitmap[pixelPosition >> 2]; + const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2; + const uint8_t raw = static_cast((byte >> bit_index) & 0x3); + if ((drawMask >> raw) & 0x01) mask |= static_cast(1u << (7 - i)); + } + if (mask == 0) continue; + const int phyBitPos = screenYBase + glyphY; + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, mask, writeState); + } + } + break; + } + + case GfxRenderer::PortraitInverted: { + for (int glyphX = 0; glyphX < glyphWidth; glyphX++) { + const int phyY = screenXBase + glyphX; + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { + const int count = std::min(8, glyphHeight - glyphY); + uint8_t mask = 0; + for (int i = 0; i < count; i++) { + const int logicalY = glyphY + (count - 1 - i); + const int pixelPosition = logicalY * glyphWidth + glyphX; + const uint8_t byte = bitmap[pixelPosition >> 2]; + const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2; + const uint8_t raw = static_cast((byte >> bit_index) & 0x3); + if ((drawMask >> raw) & 0x01) mask |= static_cast(1u << (7 - i)); + } + if (mask == 0) continue; + const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenYBase - (glyphY + count - 1); + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, mask, writeState); + } + } + break; + } + } +} + // Shared glyph rendering logic for normal and rotated text. // Coordinate mapping and cursor advance direction are selected at compile time via the template parameter. template @@ -359,6 +462,15 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode } if (is2Bit) { + if constexpr (rotation == TextRotation::None) { + // Fast path for normal text orientation. Handles all device orientations via renderGlyphFast2Bit. + renderGlyphFast2Bit(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState, + renderer.getOrientation(), renderMode); + *cursorX += glyph->advanceX; + return; + } + + // Rotated text fallback: keep explicit per-pixel behavior. int pixelPosition = 0; for (int glyphY = 0; glyphY < height; glyphY++) { const int outerCoord = outerBase + glyphY; From 1922fe38408cb697a3c0aac97e18126a172a9bb6 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 21 Feb 2026 12:37:54 +0100 Subject: [PATCH 006/301] clang-format --- lib/GfxRenderer/GfxRenderer.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 8a20e7de..1125d548 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -302,9 +302,9 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b } } -static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int rowStartPixel, - const int glyphXStartOrEnd, const int count, - const bool reverseXInChunk, const GfxRenderer::RenderMode renderMode) { +static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int rowStartPixel, const int glyphXStartOrEnd, + const int count, const bool reverseXInChunk, + const GfxRenderer::RenderMode renderMode) { // drawMask uses raw 2-bit glyph values directly from font bitmaps: // raw 0=white, 1=light gray, 2=dark gray, 3=black. // Bit N set means: draw/update when raw==N. From 1a96d804e0991614963b5dcd3c3c40029e4d2f13 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 21 Feb 2026 15:00:26 +0100 Subject: [PATCH 007/301] Minor adjustments for readability --- lib/GfxRenderer/GfxRenderer.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 1125d548..a6661464 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -302,6 +302,18 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b } } +static inline uint8_t drawMaskFor2BitMode(const GfxRenderer::RenderMode mode) { + switch (mode) { + case GfxRenderer::BW: + return 0x0E; // draw raw {1,2,3} + case GfxRenderer::GRAYSCALE_MSB: + return 0x06; // draw raw {1,2} + case GfxRenderer::GRAYSCALE_LSB: + default: + return 0x04; // draw raw {2} + } +} + static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int rowStartPixel, const int glyphXStartOrEnd, const int count, const bool reverseXInChunk, const GfxRenderer::RenderMode renderMode) { @@ -309,8 +321,7 @@ static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int ro // raw 0=white, 1=light gray, 2=dark gray, 3=black. // Bit N set means: draw/update when raw==N. // This avoids per-pixel remap (bmpVal = 3 - raw) and branch chains in the hot loop. - const uint8_t drawMask = - (renderMode == GfxRenderer::BW) ? 0x0E : ((renderMode == GfxRenderer::GRAYSCALE_MSB) ? 0x06 : 0x04); + const uint8_t drawMask = drawMaskFor2BitMode(renderMode); uint8_t mask = 0; for (int i = 0; i < count; i++) { @@ -331,8 +342,7 @@ static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const // Non-rotated text fast path for 2-bit glyphs. Writes compact masks directly to framebuffer rows. // TextRotation::Rotated90CW keeps the legacy per-pixel fallback path for safety and readability. const bool writeState = (renderMode == GfxRenderer::BW) ? pixelState : false; - const uint8_t drawMask = - (renderMode == GfxRenderer::BW) ? 0x0E : ((renderMode == GfxRenderer::GRAYSCALE_MSB) ? 0x06 : 0x04); + const uint8_t drawMask = drawMaskFor2BitMode(renderMode); switch (orientation) { case GfxRenderer::LandscapeCounterClockwise: { From d38993fd460f87a6b1e770959970615769754f75 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 21 Feb 2026 17:11:03 +0100 Subject: [PATCH 008/301] Further templating --- lib/GfxRenderer/GfxRenderer.cpp | 63 +++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index a6661464..d494ab5b 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -208,6 +208,36 @@ static inline void writeRowBits(uint8_t* const row, const int phyBitPos, const u } } +// Gather up to 8×8 bits from a 1-bit packed glyph bitmap at tile (glyphX, glyphY) +// into a contiguous uint64_t: byte 7 = first row, each byte MSB-aligned. +// stride is the glyph's full pixel-row width (in bits). +// reverseRows packs rows bottom-to-top (needed for PortraitInverted). +static inline uint64_t extractGlyphBlock(const uint8_t* const bitmap, const int stride, const int glyphX, + const int glyphY, const int rowCount, const int colCount, + const bool reverseRows) { + uint64_t pack = 0; + int bitStart = glyphY * stride + glyphX; + for (int n = 0; n < rowCount; n++, bitStart += stride) { + const int slot = reverseRows ? (rowCount - 1 - n) : n; + pack |= static_cast(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * slot); + } + return pack; +} + +// Scatter colCount column-bytes of a transposed 8×8 block into framebuffer rows. +// Physical Y for column k is: phyYBase + k * phyYStride (pass +1 or -1). +static inline void scatterBlockToFrameBuffer(uint8_t* const frameBuffer, const uint64_t pack, const int colCount, + const int phyYBase, const int phyYStride, const int phyBitPos, + const bool pixelState) { + for (int k = 0; k < colCount; k++) { + const uint8_t cols_k = static_cast(pack >> (56 - 8 * k)); + if (cols_k == 0) continue; + const int phyY = phyYBase + k * phyYStride; + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + writeRowBits(frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES, phyBitPos, cols_k, pixelState); + } +} + static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, const int glyphHeight, const int screenXBase, const int screenYBase, const bool pixelState, const GfxRenderer::Orientation orientation) { @@ -257,19 +287,10 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { const int colCount = std::min(8, glyphWidth - glyphX); - uint64_t pack = 0; - int bitStart = glyphY * glyphWidth + glyphX; - for (int n = 0; n < rowCount; n++, bitStart += glyphWidth) { - pack |= static_cast(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * n); - } - pack = transpose8x8(pack); - for (int k = 0; k < colCount; k++) { - const uint8_t cols_k = static_cast(pack >> (56 - 8 * k)); - if (cols_k == 0) continue; - const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenXBase + glyphX + k); - if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; - writeRowBits(frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES, phyBitPos, cols_k, pixelState); - } + const uint64_t pack = + transpose8x8(extractGlyphBlock(bitmap, glyphWidth, glyphX, glyphY, rowCount, colCount, false)); + scatterBlockToFrameBuffer(frameBuffer, pack, colCount, HalDisplay::DISPLAY_HEIGHT - 1 - screenXBase - glyphX, + -1, phyBitPos, pixelState); } } break; @@ -282,19 +303,9 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { const int colCount = std::min(8, glyphWidth - glyphX); - uint64_t pack = 0; - int bitStart = glyphY * glyphWidth + glyphX; - for (int n = 0; n < rowCount; n++, bitStart += glyphWidth) { - pack |= static_cast(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * (rowCount - 1 - n)); - } - pack = transpose8x8(pack); - for (int k = 0; k < colCount; k++) { - const uint8_t cols_k = static_cast(pack >> (56 - 8 * k)); - if (cols_k == 0) continue; - const int phyY = screenXBase + glyphX + k; - if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; - writeRowBits(frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES, phyBitPos, cols_k, pixelState); - } + const uint64_t pack = + transpose8x8(extractGlyphBlock(bitmap, glyphWidth, glyphX, glyphY, rowCount, colCount, true)); + scatterBlockToFrameBuffer(frameBuffer, pack, colCount, screenXBase + glyphX, 1, phyBitPos, pixelState); } } break; From 5db50363227aa90351ed9edc9a04c309475c4263 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 22 Feb 2026 09:05:29 +0100 Subject: [PATCH 009/301] Extend documentation --- lib/GfxRenderer/GfxRenderer.cpp | 116 +++++++++++++++++++++++--------- 1 file changed, 86 insertions(+), 30 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index d494ab5b..a747609d 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -176,7 +176,46 @@ static inline uint8_t bitmapExtract(const uint8_t* bitmap, const int bitPos, con return result; } -// Write up to 8 foreground bits into a physical framebuffer row. +// --------------------------------------------------------------------------- +// Fast glyph render pipeline +// --------------------------------------------------------------------------- +// Both 1-bit (BW) and 2-bit (antialiased) paths share the same structure: +// +// gather → [reindex] → scatter +// +// The glyph bitmap is a row-major 2D tensor [glyphHeight][glyphWidth]. +// The framebuffer is a row-major 2D tensor [DISPLAY_HEIGHT][DISPLAY_WIDTH_BYTES] +// (1 bpp) with a fixed row stride of DISPLAY_WIDTH_BYTES bytes. +// +// Non-rotated (Landscape): glyph rows map 1-to-1 to framebuffer rows. +// Reindex is a no-op; the pipeline is a tight per-row gather+scatter loop. +// +// Rotated 90° (Portrait): glyph rows become framebuffer columns. +// A row↔column axis swap (reindex) is required before scattering. +// +// 1-bit pipeline +// gather : extractGlyphBlock reads an 8×8 glyph tile into a +// contiguous uint64_t block +// (≈ glyphTensor[tile].contiguous()) +// reindex : transpose8x8 swaps row↔column axes in the uint64_t; +// pure index transform, no data movement +// scatter : scatterBlockToFrameBuffer → writeRowBits +// writes each column-byte to its row +// +// 2-bit pipeline (why it differs) +// The glyph stores 4 gray levels (0–3). Rendering reduces these to a 1-bit +// draw/skip decision via a render-mode threshold. That reduction is +// information-lossy, so gather and threshold cannot be separated — there is +// no contiguous 2-bit block to transpose. The two steps are fused: +// +// gather+threshold : build2BitRowMask Landscape — samples along glyph X +// build2BitColMask Portrait — samples along glyph Y +// both return a 1-bit mask ready for writeRowBits +// scatter : writeRowBits same atom as the 1-bit path +// --------------------------------------------------------------------------- + +// Scatter atom: merges 8 MSB-aligned bits into the framebuffer row at physical bit offset phyBitPos. +// Shared by both pipelines (1-bit: via scatterBlockToFrameBuffer; 2-bit: called directly). // bits — MSB-aligned; bit 7 = pixel at phyBitPos, lower (8-count) bits are zero. // phyBitPos — physical X of the MSB pixel; may be negative for left-edge partial chunks. // pixelState true → black (clear bits to 0), false → white (set bits to 1). @@ -208,10 +247,12 @@ static inline void writeRowBits(uint8_t* const row, const int phyBitPos, const u } } -// Gather up to 8×8 bits from a 1-bit packed glyph bitmap at tile (glyphX, glyphY) -// into a contiguous uint64_t: byte 7 = first row, each byte MSB-aligned. -// stride is the glyph's full pixel-row width (in bits). -// reverseRows packs rows bottom-to-top (needed for PortraitInverted). +// 1-bit pipeline step 1 — gather: reads an up-to-8×8 tile from the glyph tensor +// ([glyphHeight][glyphWidth], 1 bpp, row stride = glyphWidth bits) into a contiguous uint64_t. +// Equivalent to glyphTensor[glyphY:+rowCount, glyphX:+colCount].contiguous(). +// Byte 7 = first source row (MSB-aligned). reverseRows implements a negative-stride gather along Y +// (reads rows bottom-to-top), needed for PortraitInverted. +// Full pipeline: extractGlyphBlock (gather) → transpose8x8 (reindex) → scatterBlockToFrameBuffer (scatter). static inline uint64_t extractGlyphBlock(const uint8_t* const bitmap, const int stride, const int glyphX, const int glyphY, const int rowCount, const int colCount, const bool reverseRows) { @@ -224,8 +265,10 @@ static inline uint64_t extractGlyphBlock(const uint8_t* const bitmap, const int return pack; } -// Scatter colCount column-bytes of a transposed 8×8 block into framebuffer rows. -// Physical Y for column k is: phyYBase + k * phyYStride (pass +1 or -1). +// 1-bit pipeline step 3 — scatter: writes column-bytes of the transposed block into framebuffer rows. +// The framebuffer is a 2D tensor [DISPLAY_HEIGHT][DISPLAY_WIDTH_BYTES] with non-unit row stride; +// phyYStride=±1 selects the traversal direction along Y (positive = top-to-bottom, negative = inverted). +// Each column k maps to row (phyYBase + k*phyYStride) via writeRowBits. static inline void scatterBlockToFrameBuffer(uint8_t* const frameBuffer, const uint64_t pack, const int colCount, const int phyYBase, const int phyYStride, const int phyBitPos, const bool pixelState) { @@ -313,6 +356,19 @@ static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const b } } +// Read one pixel from a tightly-packed 2-bit-per-pixel glyph bitmap. +// The bitmap is a row-major tensor [glyphHeight][glyphWidth] with no row padding; +// its pixel-row stride equals glyphWidth. pixelPosition = row * glyphWidth + col. +// Returns the raw font value: 0=white, 1=light-gray, 2=dark-gray, 3=black. +static inline uint8_t get2BitPixel(const uint8_t* const bitmap, const int pixelPosition) { + return (bitmap[pixelPosition >> 2] >> ((3 - (pixelPosition & 3)) * 2)) & 0x3; +} + +// Convenience overload using explicit row/col/stride (tensor element access). +static inline uint8_t get2BitPixel(const uint8_t* const bitmap, const int stride, const int row, const int col) { + return get2BitPixel(bitmap, row * stride + col); +} + static inline uint8_t drawMaskFor2BitMode(const GfxRenderer::RenderMode mode) { switch (mode) { case GfxRenderer::BW: @@ -325,6 +381,10 @@ static inline uint8_t drawMaskFor2BitMode(const GfxRenderer::RenderMode mode) { } } +// 2-bit pipeline — fused gather+threshold (X axis): the 2-bit analog of extractGlyphBlock, but +// gather and threshold are collapsed into one pass. The threshold (2-bit raw value → 1-bit on/off) +// is information-lossy, so no contiguous 2-bit intermediate block can be formed mid-pipeline. +// The resulting 1-bit mask feeds writeRowBits directly (scatter). build2BitColMask is the Y-axis counterpart. static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int rowStartPixel, const int glyphXStartOrEnd, const int count, const bool reverseXInChunk, const GfxRenderer::RenderMode renderMode) { @@ -337,10 +397,23 @@ static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int ro uint8_t mask = 0; for (int i = 0; i < count; i++) { const int logicalX = reverseXInChunk ? (glyphXStartOrEnd - i) : (glyphXStartOrEnd + i); - const int pixelPosition = rowStartPixel + logicalX; - const uint8_t byte = bitmap[pixelPosition >> 2]; - const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2; - const uint8_t raw = static_cast((byte >> bit_index) & 0x3); + const uint8_t raw = get2BitPixel(bitmap, rowStartPixel + logicalX); + if ((drawMask >> raw) & 0x01) mask |= static_cast(1u << (7 - i)); + } + return mask; +} + +// 2-bit pipeline — fused gather+threshold (Y axis): column-direction counterpart to build2BitRowMask. +// Samples count pixels down glyph column glyphX starting at row glyphYStart; reverseRows implements +// a negative-stride view along Y (reads bottom-to-top), needed for PortraitInverted. +static inline uint8_t build2BitColMask(const uint8_t* const bitmap, const int glyphWidth, const int glyphX, + const int glyphYStart, const int count, const bool reverseRows, + const GfxRenderer::RenderMode renderMode) { + const uint8_t drawMask = drawMaskFor2BitMode(renderMode); + uint8_t mask = 0; + for (int i = 0; i < count; i++) { + const int row = reverseRows ? (glyphYStart + count - 1 - i) : (glyphYStart + i); + const uint8_t raw = get2BitPixel(bitmap, glyphWidth, row, glyphX); if ((drawMask >> raw) & 0x01) mask |= static_cast(1u << (7 - i)); } return mask; @@ -353,7 +426,6 @@ static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const // Non-rotated text fast path for 2-bit glyphs. Writes compact masks directly to framebuffer rows. // TextRotation::Rotated90CW keeps the legacy per-pixel fallback path for safety and readability. const bool writeState = (renderMode == GfxRenderer::BW) ? pixelState : false; - const uint8_t drawMask = drawMaskFor2BitMode(renderMode); switch (orientation) { case GfxRenderer::LandscapeCounterClockwise: { @@ -400,15 +472,7 @@ static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { const int count = std::min(8, glyphHeight - glyphY); - uint8_t mask = 0; - for (int i = 0; i < count; i++) { - const int logicalY = glyphY + i; - const int pixelPosition = logicalY * glyphWidth + glyphX; - const uint8_t byte = bitmap[pixelPosition >> 2]; - const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2; - const uint8_t raw = static_cast((byte >> bit_index) & 0x3); - if ((drawMask >> raw) & 0x01) mask |= static_cast(1u << (7 - i)); - } + const uint8_t mask = build2BitColMask(bitmap, glyphWidth, glyphX, glyphY, count, false, renderMode); if (mask == 0) continue; const int phyBitPos = screenYBase + glyphY; if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; @@ -425,15 +489,7 @@ static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { const int count = std::min(8, glyphHeight - glyphY); - uint8_t mask = 0; - for (int i = 0; i < count; i++) { - const int logicalY = glyphY + (count - 1 - i); - const int pixelPosition = logicalY * glyphWidth + glyphX; - const uint8_t byte = bitmap[pixelPosition >> 2]; - const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2; - const uint8_t raw = static_cast((byte >> bit_index) & 0x3); - if ((drawMask >> raw) & 0x01) mask |= static_cast(1u << (7 - i)); - } + const uint8_t mask = build2BitColMask(bitmap, glyphWidth, glyphX, glyphY, count, true, renderMode); if (mask == 0) continue; const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenYBase - (glyphY + count - 1); if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; From bc68d3b2384658a7db655f3fdcf85a581b2e8747 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 22 Feb 2026 11:26:39 +0100 Subject: [PATCH 010/301] Further updates --- lib/GfxRenderer/GfxRenderer.cpp | 230 ++++++++++++++++++++++++-------- lib/GfxRenderer/GfxRenderer.h | 3 +- 2 files changed, 178 insertions(+), 55 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index a747609d..5055c364 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -369,30 +369,28 @@ static inline uint8_t get2BitPixel(const uint8_t* const bitmap, const int stride return get2BitPixel(bitmap, row * stride + col); } -static inline uint8_t drawMaskFor2BitMode(const GfxRenderer::RenderMode mode) { - switch (mode) { - case GfxRenderer::BW: - return 0x0E; // draw raw {1,2,3} - case GfxRenderer::GRAYSCALE_MSB: - return 0x06; // draw raw {1,2} - case GfxRenderer::GRAYSCALE_LSB: - default: - return 0x04; // draw raw {2} - } +template +static constexpr uint8_t drawMaskFor2BitMode() { + if constexpr (mode == GfxRenderer::BW) + return 0x0E; // draw raw {1,2,3} + else if constexpr (mode == GfxRenderer::GRAYSCALE_MSB) + return 0x06; // draw raw {1,2} + else + return 0x04; // GRAYSCALE_LSB: draw raw {2} } // 2-bit pipeline — fused gather+threshold (X axis): the 2-bit analog of extractGlyphBlock, but // gather and threshold are collapsed into one pass. The threshold (2-bit raw value → 1-bit on/off) // is information-lossy, so no contiguous 2-bit intermediate block can be formed mid-pipeline. // The resulting 1-bit mask feeds writeRowBits directly (scatter). build2BitColMask is the Y-axis counterpart. +template static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int rowStartPixel, const int glyphXStartOrEnd, - const int count, const bool reverseXInChunk, - const GfxRenderer::RenderMode renderMode) { + const int count, const bool reverseXInChunk) { // drawMask uses raw 2-bit glyph values directly from font bitmaps: // raw 0=white, 1=light gray, 2=dark gray, 3=black. // Bit N set means: draw/update when raw==N. - // This avoids per-pixel remap (bmpVal = 3 - raw) and branch chains in the hot loop. - const uint8_t drawMask = drawMaskFor2BitMode(renderMode); + // Compile-time constant lets the compiler reduce (drawMask >> raw) & 1 to a single comparison. + constexpr uint8_t drawMask = drawMaskFor2BitMode(); uint8_t mask = 0; for (int i = 0; i < count; i++) { @@ -403,13 +401,64 @@ static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int ro return mask; } +// Fast-path 2-bit mask builder for 8 byte-aligned pixels. +// +// The 2-bit glyph bitmap stores 4 pixels per byte, MSB-first: +// byte b = [p0.msb p0.lsb p1.msb p1.lsb p2.msb p2.lsb p3.msb p3.lsb] +// +// For each render mode the draw decision collapses to a two-bit boolean: +// BW (draw if raw ≠ 0): msb | lsb +// GRAYSCALE_MSB (draw if raw ∈ {1,2}): msb ^ lsb +// GRAYSCALE_LSB (draw if raw == 2): msb & ~lsb +// +// Derivation for one byte: +// msb_bits = b & 0xAA → bits 7,5,3,1 hold p0.msb … p3.msb; bits 6,4,2,0 = 0 +// lsb_bits = (b & 0x55) << 1 → same positions hold p0.lsb … p3.lsb +// draw_bits = msb_bits OP lsb_bits → bits 7,5,3,1 are the per-pixel draw flags +// +// compact4: squeezes those 4 draw flags from bit positions 7,5,3,1 +// into the top nibble (bits 7,6,5,4 → pixels 0,1,2,3). +// +// Two bytes b0 (pixels 0–3) and b1 (pixels 4–7) are combined: +// mask = compact4(draw(b0)) | (compact4(draw(b1)) >> 4) +// +// This avoids the 8-iteration per-pixel loop in build2BitRowMask and +// processes the full 8-pixel chunk in ~16 ALU ops instead of ~56. +// The caller is responsible for only calling this when pixelStart is +// 4-pixel (1-byte) aligned (pixelStart & 3 == 0) and count == 8. +template +static inline uint8_t build2BitRowMaskFromTwoBytes(const uint8_t b0, const uint8_t b1) { + const uint8_t msb0 = b0 & 0xAA; + const uint8_t lsb0 = (b0 & 0x55) << 1; + const uint8_t msb1 = b1 & 0xAA; + const uint8_t lsb1 = (b1 & 0x55) << 1; + + uint8_t draw0, draw1; + if constexpr (mode == GfxRenderer::BW) { + draw0 = msb0 | lsb0; + draw1 = msb1 | lsb1; + } else if constexpr (mode == GfxRenderer::GRAYSCALE_MSB) { + draw0 = msb0 ^ lsb0; + draw1 = msb1 ^ lsb1; + } else { // GRAYSCALE_LSB + draw0 = msb0 & ~lsb0; + draw1 = msb1 & ~lsb1; + } + + // Compact each nibble's draw flags from bit positions 7,5,3,1 → 7,6,5,4. + auto compact4 = [](const uint8_t d) -> uint8_t { + return (d & 0x80) | ((d & 0x20) << 1) | ((d & 0x08) << 2) | ((d & 0x02) << 3); + }; + return compact4(draw0) | (compact4(draw1) >> 4); +} + // 2-bit pipeline — fused gather+threshold (Y axis): column-direction counterpart to build2BitRowMask. // Samples count pixels down glyph column glyphX starting at row glyphYStart; reverseRows implements // a negative-stride view along Y (reads bottom-to-top), needed for PortraitInverted. +template static inline uint8_t build2BitColMask(const uint8_t* const bitmap, const int glyphWidth, const int glyphX, - const int glyphYStart, const int count, const bool reverseRows, - const GfxRenderer::RenderMode renderMode) { - const uint8_t drawMask = drawMaskFor2BitMode(renderMode); + const int glyphYStart, const int count, const bool reverseRows) { + constexpr uint8_t drawMask = drawMaskFor2BitMode(); uint8_t mask = 0; for (int i = 0; i < count; i++) { const int row = reverseRows ? (glyphYStart + count - 1 - i) : (glyphYStart + i); @@ -419,13 +468,37 @@ static inline uint8_t build2BitColMask(const uint8_t* const bitmap, const int gl return mask; } +// Shared body for Portrait and PortraitInverted 2-bit rendering. +// inverted=false → Portrait (phyY counts down, phyBitPos counts up). +// inverted=true → PortraitInverted (phyY counts up, phyBitPos counts down). +// Both template params are compile-time constants; all ternaries fold away. +template +static void renderGlyphFast2BitPortrait(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, + const int glyphHeight, const int screenXBase, const int screenYBase, + const bool writeState) { + for (int glyphX = 0; glyphX < glyphWidth; glyphX++) { + const int phyY = inverted ? (screenXBase + glyphX) : (HalDisplay::DISPLAY_HEIGHT - 1 - (screenXBase + glyphX)); + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { + const int count = std::min(8, glyphHeight - glyphY); + const uint8_t mask = build2BitColMask(bitmap, glyphWidth, glyphX, glyphY, count, inverted); + if (mask == 0) continue; + const int phyBitPos = + inverted ? (HalDisplay::DISPLAY_WIDTH - 1 - screenYBase - (glyphY + count - 1)) : (screenYBase + glyphY); + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, mask, writeState); + } + } +} + +template static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, const int glyphHeight, const int screenXBase, const int screenYBase, - const bool pixelState, const GfxRenderer::Orientation orientation, - const GfxRenderer::RenderMode renderMode) { + const bool pixelState, const GfxRenderer::Orientation orientation) { // Non-rotated text fast path for 2-bit glyphs. Writes compact masks directly to framebuffer rows. // TextRotation::Rotated90CW keeps the legacy per-pixel fallback path for safety and readability. - const bool writeState = (renderMode == GfxRenderer::BW) ? pixelState : false; + const bool writeState = (mode == GfxRenderer::BW) ? pixelState : false; switch (orientation) { case GfxRenderer::LandscapeCounterClockwise: { @@ -436,7 +509,14 @@ static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const const int rowStartPixel = glyphY * glyphWidth; for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { const int count = std::min(8, glyphWidth - glyphX); - const uint8_t mask = build2BitRowMask(bitmap, rowStartPixel, glyphX, count, false, renderMode); + const int pixelStart = rowStartPixel + glyphX; + uint8_t mask; + if (count == 8 && (pixelStart & 3) == 0) { + const int srcByteIdx = pixelStart >> 2; + mask = build2BitRowMaskFromTwoBytes(bitmap[srcByteIdx], bitmap[srcByteIdx + 1]); + } else { + mask = build2BitRowMask(bitmap, rowStartPixel, glyphX, count, false); + } if (mask == 0) continue; const int phyBitPos = screenXBase + glyphX; if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; @@ -447,6 +527,9 @@ static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const } case GfxRenderer::LandscapeClockwise: { + // Row-outer/chunk-inner: framebuffer rows are written at stride -DISPLAY_WIDTH_BYTES + // (phyY decreases as glyphY increases). Keeping row-outer preserves sequential access + // within each row, which is more cache-friendly than the chunk-outer alternative. for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenYBase + glyphY); if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; @@ -455,7 +538,14 @@ static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) { const int chunkStart = std::max(0, chunkEnd - 7); const int count = chunkEnd - chunkStart + 1; - const uint8_t mask = build2BitRowMask(bitmap, rowStartPixel, chunkEnd, count, true, renderMode); + const int pixelStart = rowStartPixel + chunkStart; + uint8_t mask; + if (count == 8 && (pixelStart & 3) == 0) { + const int srcByteIdx = pixelStart >> 2; + mask = reverseBits8(build2BitRowMaskFromTwoBytes(bitmap[srcByteIdx], bitmap[srcByteIdx + 1])); + } else { + mask = build2BitRowMask(bitmap, rowStartPixel, chunkEnd, count, true); + } if (mask == 0) continue; const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenXBase - chunkEnd; if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; @@ -465,39 +555,15 @@ static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const break; } - case GfxRenderer::Portrait: { - for (int glyphX = 0; glyphX < glyphWidth; glyphX++) { - const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenXBase + glyphX); - if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; - uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; - for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { - const int count = std::min(8, glyphHeight - glyphY); - const uint8_t mask = build2BitColMask(bitmap, glyphWidth, glyphX, glyphY, count, false, renderMode); - if (mask == 0) continue; - const int phyBitPos = screenYBase + glyphY; - if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; - writeRowBits(row, phyBitPos, mask, writeState); - } - } + case GfxRenderer::Portrait: + renderGlyphFast2BitPortrait(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase, screenYBase, + writeState); break; - } - case GfxRenderer::PortraitInverted: { - for (int glyphX = 0; glyphX < glyphWidth; glyphX++) { - const int phyY = screenXBase + glyphX; - if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; - uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; - for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { - const int count = std::min(8, glyphHeight - glyphY); - const uint8_t mask = build2BitColMask(bitmap, glyphWidth, glyphX, glyphY, count, true, renderMode); - if (mask == 0) continue; - const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenYBase - (glyphY + count - 1); - if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; - writeRowBits(row, phyBitPos, mask, writeState); - } - } + case GfxRenderer::PortraitInverted: + renderGlyphFast2BitPortrait(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase, screenYBase, + writeState); break; - } } } @@ -541,8 +607,21 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode if (is2Bit) { if constexpr (rotation == TextRotation::None) { // Fast path for normal text orientation. Handles all device orientations via renderGlyphFast2Bit. - renderGlyphFast2Bit(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState, - renderer.getOrientation(), renderMode); + // Dispatch on renderMode at compile time so each specialization gets a constant drawMask. + switch (renderMode) { + case GfxRenderer::BW: + renderGlyphFast2Bit(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, + pixelState, renderer.getOrientation()); + break; + case GfxRenderer::GRAYSCALE_MSB: + renderGlyphFast2Bit(renderer.getFrameBuffer(), bitmap, width, height, innerBase, + outerBase, pixelState, renderer.getOrientation()); + break; + case GfxRenderer::GRAYSCALE_LSB: + renderGlyphFast2Bit(renderer.getFrameBuffer(), bitmap, width, height, innerBase, + outerBase, pixelState, renderer.getOrientation()); + break; + } *cursorX += glyph->advanceX; return; } @@ -734,6 +813,49 @@ void GfxRenderer::drawTextBWLegacy(const int fontId, const int x, const int y, c xPos += glyph->advanceX; } } + +// Legacy per-pixel rendering path — mirrors the old renderCharImpl 2-bit BW loop. +// Used only by the renderChar benchmark to establish the baseline for antialiased fonts. +void GfxRenderer::drawText2BitLegacy(const int fontId, const int x, const int y, const char* text) const { + if (text == nullptr || *text == '\0') return; + const auto fontIt = fontMap.find(fontId); + if (fontIt == fontMap.end()) return; + const auto& fontFamily = fontIt->second; + + int yPos = y + getFontAscenderSize(fontId); + int xPos = x; + uint32_t cp; + while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + const EpdGlyph* glyph = fontFamily.getGlyph(cp, EpdFontFamily::REGULAR); + if (!glyph) glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, EpdFontFamily::REGULAR); + if (!glyph) continue; + const EpdFontData* fontData = fontFamily.getData(EpdFontFamily::REGULAR); + if (!fontData->is2Bit) { + xPos += glyph->advanceX; + continue; + } + const uint8_t* bitmap = getGlyphBitmap(fontData, glyph); + if (bitmap != nullptr) { + const int screenYBase = yPos - glyph->top; + const int screenXBase = xPos + glyph->left; + int pixelPosition = 0; + for (int glyphY = 0; glyphY < glyph->height; glyphY++) { + for (int glyphX = 0; glyphX < glyph->width; glyphX++, pixelPosition++) { + // 2-bit: each pixel occupies 2 bits; MSB first within each byte + const uint8_t raw = (bitmap[pixelPosition >> 2] >> (6 - ((pixelPosition & 3) << 1))) & 3; + if (!raw) continue; + int phyX, phyY; + rotateCoordinates(orientation, screenXBase + glyphX, screenYBase + glyphY, &phyX, &phyY); + if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX / 8); + const uint8_t bitPosition = 7 - (phyX % 8); + frameBuffer[byteIndex] &= ~(1 << bitPosition); // black pixel + } + } + } + xPos += glyph->advanceX; + } +} #endif // ENABLE_RENDERCHAR_BENCHMARK void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index f774d9c7..1783612f 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -139,7 +139,8 @@ class GfxRenderer { static size_t getBufferSize(); #ifdef ENABLE_RENDERCHAR_BENCHMARK - // Legacy (per-pixel drawPixel) text rendering — used only by the renderChar benchmark. + // Legacy per-pixel paths — used only by the renderChar benchmark to establish baselines. void drawTextBWLegacy(int fontId, int x, int y, const char* text) const; + void drawText2BitLegacy(int fontId, int x, int y, const char* text) const; #endif }; From b3aae93f4194e087c1a91f922e9dcdf078609e17 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 25 Feb 2026 20:14:21 +0100 Subject: [PATCH 011/301] Cache KOReader document hash --- lib/KOReaderSync/KOReaderDocumentId.cpp | 76 +++++++++++++++++++++++++ lib/KOReaderSync/KOReaderDocumentId.h | 11 ++++ 2 files changed, 87 insertions(+) diff --git a/lib/KOReaderSync/KOReaderDocumentId.cpp b/lib/KOReaderSync/KOReaderDocumentId.cpp index efb18d1b..434748c4 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.cpp +++ b/lib/KOReaderSync/KOReaderDocumentId.cpp @@ -4,6 +4,8 @@ #include #include +#include + namespace { // Extract filename from path (everything after last '/') std::string getFilename(const std::string& path) { @@ -15,6 +17,69 @@ std::string getFilename(const std::string& path) { } } // namespace +std::string KOReaderDocumentId::getCacheFilePath(const std::string& filePath) { + // Mirror the Epub cache directory convention so the hash file shares the + // same per-book folder as other cached data. + return std::string("/.crosspoint/epub_") + + std::to_string(std::hash{}(filePath)) + + "/koreader_docid.txt"; +} + +std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, + const size_t fileSize) { + if (!Storage.exists(cacheFilePath.c_str())) { + return ""; + } + + const String content = Storage.readFile(cacheFilePath.c_str()); + if (content.isEmpty()) { + return ""; + } + + // Format: "\n<32-char-hex-hash>" + const int newlinePos = content.indexOf('\n'); + if (newlinePos < 0) { + return ""; + } + + const size_t cachedSize = static_cast(content.substring(0, newlinePos).toInt()); + if (cachedSize != fileSize) { + LOG_DBG("KODoc", "Hash cache invalidated: file size changed (%zu -> %zu)", cachedSize, fileSize); + return ""; + } + + std::string hash = content.substring(newlinePos + 1).c_str(); + // Trim any trailing whitespace / line endings + while (!hash.empty() && (hash.back() == '\n' || hash.back() == '\r' || hash.back() == ' ')) { + hash.pop_back(); + } + + if (hash.size() != 32) { + return ""; + } + + LOG_DBG("KODoc", "Hash cache hit: %s", hash.c_str()); + return hash; +} + +void KOReaderDocumentId::saveCachedHash(const std::string& cacheFilePath, + const size_t fileSize, + const std::string& hash) { + // Ensure the book's cache directory exists before writing + const size_t lastSlash = cacheFilePath.rfind('/'); + if (lastSlash != std::string::npos) { + Storage.ensureDirectoryExists(cacheFilePath.substr(0, lastSlash).c_str()); + } + + String content(std::to_string(fileSize).c_str()); + content += '\n'; + content += hash.c_str(); + + if (!Storage.writeFile(cacheFilePath.c_str(), content)) { + LOG_DBG("KODoc", "Failed to write hash cache to %s", cacheFilePath.c_str()); + } +} + std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePath) { const std::string filename = getFilename(filePath); if (filename.empty()) { @@ -49,6 +114,15 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { } const size_t fileSize = file.fileSize(); + + // Return persisted hash if the file size hasn't changed since it was cached + const std::string cacheFilePath = getCacheFilePath(filePath); + const std::string cached = loadCachedHash(cacheFilePath, fileSize); + if (!cached.empty()) { + file.close(); + return cached; + } + LOG_DBG("KODoc", "Calculating hash for file: %s (size: %zu)", filePath.c_str(), fileSize); // Initialize MD5 builder @@ -92,5 +166,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead); + saveCachedHash(cacheFilePath, fileSize, result); + return result; } diff --git a/lib/KOReaderSync/KOReaderDocumentId.h b/lib/KOReaderSync/KOReaderDocumentId.h index 2b6189e2..a78c134f 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.h +++ b/lib/KOReaderSync/KOReaderDocumentId.h @@ -42,4 +42,15 @@ class KOReaderDocumentId { // Calculate offset for index i: 1024 << (2*i) static size_t getOffset(int i); + + // Hash cache helpers + // Returns the path to the per-book cache file that stores the precomputed hash. + // Uses the same directory convention as the Epub cache (/.crosspoint/epub_/). + static std::string getCacheFilePath(const std::string& filePath); + + // Returns the cached hash if the file size matches, or empty string on miss/invalidation. + static std::string loadCachedHash(const std::string& cacheFilePath, size_t fileSize); + + // Persists the computed hash alongside the file size used to compute it. + static void saveCachedHash(const std::string& cacheFilePath, size_t fileSize, const std::string& hash); }; From 3ca525ef3a8e2a56cf3358614ac5d9349b84617a Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 25 Feb 2026 21:06:51 +0100 Subject: [PATCH 012/301] Add fingerprint --- lib/KOReaderSync/KOReaderDocumentId.cpp | 106 ++++++++++++++++++++---- lib/KOReaderSync/KOReaderDocumentId.h | 21 ++++- 2 files changed, 108 insertions(+), 19 deletions(-) diff --git a/lib/KOReaderSync/KOReaderDocumentId.cpp b/lib/KOReaderSync/KOReaderDocumentId.cpp index 434748c4..0d5ea9b3 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.cpp +++ b/lib/KOReaderSync/KOReaderDocumentId.cpp @@ -20,13 +20,11 @@ std::string getFilename(const std::string& path) { std::string KOReaderDocumentId::getCacheFilePath(const std::string& filePath) { // Mirror the Epub cache directory convention so the hash file shares the // same per-book folder as other cached data. - return std::string("/.crosspoint/epub_") + - std::to_string(std::hash{}(filePath)) + - "/koreader_docid.txt"; + return std::string("/.crosspoint/epub_") + std::to_string(std::hash{}(filePath)) + "/koreader_docid.txt"; } -std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, - const size_t fileSize) { +std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, const size_t fileSize, + const std::string& currentFingerprint) { if (!Storage.exists(cacheFilePath.c_str())) { return ""; } @@ -36,42 +34,105 @@ std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, return ""; } - // Format: "\n<32-char-hex-hash>" + // Format: ":\n<32-char-hex-hash>" const int newlinePos = content.indexOf('\n'); if (newlinePos < 0) { return ""; } - const size_t cachedSize = static_cast(content.substring(0, newlinePos).toInt()); - if (cachedSize != fileSize) { - LOG_DBG("KODoc", "Hash cache invalidated: file size changed (%zu -> %zu)", cachedSize, fileSize); + const String header = content.substring(0, newlinePos); + const int colonPos = header.indexOf(':'); + if (colonPos < 0) { + LOG_DBG("KODoc", "Hash cache invalidated: header missing fingerprint"); return ""; } + const String sizeTok = header.substring(0, colonPos); + const String fpTok = header.substring(colonPos + 1); + + // Validate the filesize token – it must consist of ASCII digits and parse + // correctly to the expected size. + bool digitsOnly = true; + for (size_t i = 0; i < sizeTok.length(); ++i) { + const char ch = sizeTok[i]; + if (ch < '0' || ch > '9') { + digitsOnly = false; + break; + } + } + if (!digitsOnly) { + LOG_DBG("KODoc", "Hash cache invalidated: size token not numeric ('%s')", sizeTok.c_str()); + return ""; + } + + const long parsed = sizeTok.toInt(); + if (parsed < 0) { + LOG_DBG("KODoc", "Hash cache invalidated: size token parse error ('%s')", sizeTok.c_str()); + return ""; + } + const size_t cachedSize = static_cast(parsed); + if (cachedSize != fileSize) { + LOG_DBG("KODoc", "Hash cache invalidated: file size or fingerprint changed (%zu -> %zu)", cachedSize, fileSize); + return ""; + } + + // Validate stored fingerprint format (8 hex characters) + if (fpTok.length() != 8) { + LOG_DBG("KODoc", "Hash cache invalidated: bad fingerprint length (%zu)", fpTok.length()); + return ""; + } + for (size_t i = 0; i < fpTok.length(); ++i) { + char c = fpTok[i]; + bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in fingerprint", c); + return ""; + } + } + + { + String currentFpStr(currentFingerprint.c_str()); + if (fpTok != currentFpStr) { + LOG_DBG("KODoc", "Hash cache invalidated: fingerprint changed (%s != %s)", fpTok.c_str(), + currentFingerprint.c_str()); + return ""; + } + } + std::string hash = content.substring(newlinePos + 1).c_str(); // Trim any trailing whitespace / line endings while (!hash.empty() && (hash.back() == '\n' || hash.back() == '\r' || hash.back() == ' ')) { hash.pop_back(); } + // Hash must be exactly 32 hex characters. if (hash.size() != 32) { + LOG_DBG("KODoc", "Hash cache invalidated: wrong hash length (%zu)", hash.size()); return ""; } + for (char c : hash) { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in hash", c); + return ""; + } + } LOG_DBG("KODoc", "Hash cache hit: %s", hash.c_str()); return hash; } -void KOReaderDocumentId::saveCachedHash(const std::string& cacheFilePath, - const size_t fileSize, - const std::string& hash) { +void KOReaderDocumentId::saveCachedHash(const std::string& cacheFilePath, const size_t fileSize, + const std::string& fingerprint, const std::string& hash) { // Ensure the book's cache directory exists before writing const size_t lastSlash = cacheFilePath.rfind('/'); if (lastSlash != std::string::npos) { Storage.ensureDirectoryExists(cacheFilePath.substr(0, lastSlash).c_str()); } + // Format: ":\n" String content(std::to_string(fileSize).c_str()); + content += ':'; + content += fingerprint.c_str(); content += '\n'; content += hash.c_str(); @@ -115,9 +176,24 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { const size_t fileSize = file.fileSize(); - // Return persisted hash if the file size hasn't changed since it was cached + // Compute a lightweight fingerprint from the file's modification time. + // The underlying FsFile API provides getModifyDateTime which returns two + // packed 16-bit values (date and time). Concatenate these as eight hex + // digits to produce the token stored in the cache header. + uint16_t date = 0, time = 0; + if (!file.getModifyDateTime(&date, &time)) { + // If timestamp isn't available for some reason, fall back to a sentinel. + date = 0; + time = 0; + } + char fpBuf[9]; + // two 16-bit numbers => 4 hex digits each + sprintf(fpBuf, "%04x%04x", date, time); + const std::string fingerprintTok(fpBuf); + + // Return persisted hash if the file size and fingerprint haven't changed. const std::string cacheFilePath = getCacheFilePath(filePath); - const std::string cached = loadCachedHash(cacheFilePath, fileSize); + const std::string cached = loadCachedHash(cacheFilePath, fileSize, fingerprintTok); if (!cached.empty()) { file.close(); return cached; @@ -166,7 +242,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead); - saveCachedHash(cacheFilePath, fileSize, result); + saveCachedHash(cacheFilePath, fileSize, fingerprintTok, result); return result; } diff --git a/lib/KOReaderSync/KOReaderDocumentId.h b/lib/KOReaderSync/KOReaderDocumentId.h index a78c134f..de487c23 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.h +++ b/lib/KOReaderSync/KOReaderDocumentId.h @@ -48,9 +48,22 @@ class KOReaderDocumentId { // Uses the same directory convention as the Epub cache (/.crosspoint/epub_/). static std::string getCacheFilePath(const std::string& filePath); - // Returns the cached hash if the file size matches, or empty string on miss/invalidation. - static std::string loadCachedHash(const std::string& cacheFilePath, size_t fileSize); + // Returns the cached hash if the file size and fingerprint match, or empty + // string on miss/invalidation. + // + // The fingerprint is derived from the file's modification timestamp. We + // call `FsFile::getModifyDateTime` to retrieve the packed date/time fields + // from the filesystem. These two 16‑bit values are concatenated as + // eight hex digits (YYYYYYTTTT? actually date and time bits) and used as a + // lightweight change signal; any change to the file's mtime will cause the + // fingerprint to differ and the cache to be invalidated. Since the full + // document hash is expensive to compute, using mtime gives us a quick way to + // detect modifications without reading file contents. + static std::string loadCachedHash(const std::string& cacheFilePath, size_t fileSize, + const std::string& currentFingerprint); - // Persists the computed hash alongside the file size used to compute it. - static void saveCachedHash(const std::string& cacheFilePath, size_t fileSize, const std::string& hash); + // Persists the computed hash alongside the file size and fingerprint (the + // modification-timestamp token) used to generate it. + static void saveCachedHash(const std::string& cacheFilePath, size_t fileSize, const std::string& fingerprint, + const std::string& hash); }; From 39eb75f1c9e11456fa5a268b08cd1be273a99321 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 25 Feb 2026 21:29:39 +0100 Subject: [PATCH 013/301] Nitpick comment --- lib/KOReaderSync/KOReaderDocumentId.h | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/KOReaderSync/KOReaderDocumentId.h b/lib/KOReaderSync/KOReaderDocumentId.h index de487c23..5f226eb5 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.h +++ b/lib/KOReaderSync/KOReaderDocumentId.h @@ -52,13 +52,16 @@ class KOReaderDocumentId { // string on miss/invalidation. // // The fingerprint is derived from the file's modification timestamp. We - // call `FsFile::getModifyDateTime` to retrieve the packed date/time fields - // from the filesystem. These two 16‑bit values are concatenated as - // eight hex digits (YYYYYYTTTT? actually date and time bits) and used as a - // lightweight change signal; any change to the file's mtime will cause the - // fingerprint to differ and the cache to be invalidated. Since the full - // document hash is expensive to compute, using mtime gives us a quick way to - // detect modifications without reading file contents. + // call `FsFile::getModifyDateTime` to retrieve two 16‑bit packed values + // supplied by the filesystem: one for the date and one for the time. These + // are concatenated and represented as eight hexadecimal digits in the form + //