Files
Crosspoint/lib/GfxRenderer/GfxRenderer.cpp
T

2697 lines
108 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "GfxRenderer.h"
#include <FontDecompressor.h>
#include <HalGPIO.h>
#include <Logging.h>
#include <SdCardFont.h>
#include <Utf8.h>
#include <esp_heap_caps.h>
#include <algorithm>
#include <cassert>
#include <cstring>
#include "FontCacheManager.h"
const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const {
if (fontData->groups != nullptr) {
auto* fd = fontCacheManager_ ? fontCacheManager_->getDecompressor() : nullptr;
if (!fd) {
LOG_ERR("GFX", "Compressed font but no FontDecompressor set");
return nullptr;
}
uint32_t glyphIndex = static_cast<uint32_t>(glyph - fontData->glyph);
// For page-buffer hits the pointer is stable for the page lifetime.
// For hot-group hits it is valid only until the next getBitmap() call — callers
// must consume it (draw the glyph) before requesting another bitmap.
return fd->getBitmap(fontData, glyph, glyphIndex);
}
// For SD card fonts, check if the glyph was loaded on demand into the overflow
// buffer. getOverflowBitmap() returns:
// - bitmap pointer for overflow glyphs with bitmap data
// - nullptr for overflow glyphs without bitmap data (e.g. space: width=0, height=0)
// - nullptr for non-overflow glyphs (normal prewarmed path)
// We distinguish overflow-with-no-bitmap from non-overflow by checking isOverflowGlyph().
if (fontData->glyphMissCtx) {
auto* sdFont = SdCardFont::fromMissCtx(fontData->glyphMissCtx);
if (sdFont->isOverflowGlyph(glyph)) {
return sdFont->getOverflowBitmap(glyph); // may be nullptr for zero-width glyphs
}
}
return &fontData->bitmap[glyph->dataOffset];
}
void GfxRenderer::ensureSdCardFontReady(int fontId, const char* utf8Text) const {
auto it = sdCardFonts_.find(fontId);
if (it != sdCardFonts_.end()) {
// Metadata-only: loads glyph metrics (advanceX) without bitmap data.
// Saves ~50-100KB heap vs full prewarm — layout only needs advance widths.
// Prewarm all present styles (0x0F) for layout measurement.
int missed = it->second->prewarm(utf8Text, 0x0F, /*metadataOnly=*/true,
/*loadKernLigatureData=*/true);
if (missed > 0) {
LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed);
}
}
}
void GfxRenderer::clearSdCardFontAccumulation() const {
for (auto& [id, font] : sdCardFonts_) {
font->clearAccumulation();
}
}
void GfxRenderer::begin() {
frameBuffer = display.getFrameBuffer();
if (!frameBuffer) {
LOG_ERR("GFX", "!! No framebuffer");
assert(false);
}
panelWidth = display.getDisplayWidth();
panelHeight = display.getDisplayHeight();
panelWidthBytes = display.getDisplayWidthBytes();
frameBufferSize = display.getBufferSize();
bwSnapshotRowStart = 0;
bwSnapshotRowEnd = 0;
bwSnapshotSizeBytes = 0;
bwBufferChunkSize = BW_BUFFER_CHUNK_SIZE;
bwBufferChunks.assign((frameBufferSize + bwBufferChunkSize - 1) / bwBufferChunkSize, nullptr);
}
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
auto result = fontMap.insert({fontId, font});
if (!result.second) {
LOG_ERR("GFX", "Font ID %d already registered, ignoring duplicate", fontId);
}
}
// Translate logical (x,y) coordinates to physical panel coordinates based on current orientation
// This should always be inlined for better performance
static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, const int x, const int y, int* phyX,
int* phyY, const uint16_t panelWidth, const uint16_t panelHeight) {
switch (orientation) {
case GfxRenderer::Portrait: {
// Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees clockwise
*phyX = y;
*phyY = panelHeight - 1 - x;
break;
}
case GfxRenderer::LandscapeClockwise: {
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
*phyX = panelWidth - 1 - x;
*phyY = panelHeight - 1 - y;
break;
}
case GfxRenderer::PortraitInverted: {
// Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees counter-clockwise
*phyX = panelWidth - 1 - y;
*phyY = x;
break;
}
case GfxRenderer::LandscapeCounterClockwise: {
// Logical landscape (800x480) aligned with panel orientation
*phyX = x;
*phyY = y;
break;
}
}
}
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.53.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<uint8_t>(0xFF << (8 - count));
return result;
}
// ---------------------------------------------------------------------------
// 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 (03). 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).
static inline void writeRowBits(uint8_t* const row, const int phyBitPos, const uint8_t bits, const bool pixelState,
const int widthBytes) {
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 {
byteIdx = phyBitPos >> 3;
shift = phyBitPos & 7;
}
if (pixelState) {
row[byteIdx] &= ~(effectiveBits >> shift);
if (shift > 0 && byteIdx + 1 < widthBytes) row[byteIdx + 1] &= ~(uint8_t)(effectiveBits << (8 - shift));
} else {
row[byteIdx] |= (effectiveBits >> shift);
if (shift > 0 && byteIdx + 1 < widthBytes) row[byteIdx + 1] |= (uint8_t)(effectiveBits << (8 - shift));
}
}
// 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) {
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<uint64_t>(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * slot);
}
return pack;
}
// 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, const int displayHeight, const int widthBytes) {
for (int k = 0; k < colCount; k++) {
const uint8_t cols_k = static_cast<uint8_t>(pack >> (56 - 8 * k));
if (cols_k == 0) continue;
const int phyY = phyYBase + k * phyYStride;
if (phyY < 0 || phyY >= displayHeight) continue;
writeRowBits(frameBuffer + phyY * widthBytes, phyBitPos, cols_k, pixelState, widthBytes);
}
}
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, const int displayWidth,
const int displayHeight, const int widthBytes) {
switch (orientation) {
case GfxRenderer::LandscapeCounterClockwise: {
for (int glyphY = 0; glyphY < glyphHeight; glyphY++) {
const int phyY = screenYBase + glyphY;
if (phyY < 0 || phyY >= displayHeight) continue;
uint8_t* const row = frameBuffer + phyY * widthBytes;
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 >= displayWidth) continue;
writeRowBits(row, phyBitPos, gbyte, pixelState, widthBytes);
}
}
break;
}
case GfxRenderer::LandscapeClockwise: {
for (int glyphY = 0; glyphY < glyphHeight; glyphY++) {
const int phyY = displayHeight - 1 - (screenYBase + glyphY);
if (phyY < 0 || phyY >= displayHeight) continue;
uint8_t* const row = frameBuffer + phyY * widthBytes;
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;
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 = displayWidth - 1 - screenXBase - chunkEnd;
if (phyBitPos + count <= 0 || phyBitPos >= displayWidth) continue;
writeRowBits(row, phyBitPos, gbyte, pixelState, widthBytes);
}
}
break;
}
case GfxRenderer::Portrait: {
for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) {
const int rowCount = std::min(8, glyphHeight - glyphY);
const int phyBitPos = screenYBase + glyphY;
if (phyBitPos + rowCount <= 0 || phyBitPos >= displayWidth) continue;
for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) {
const int colCount = std::min(8, glyphWidth - glyphX);
const uint64_t pack =
transpose8x8(extractGlyphBlock(bitmap, glyphWidth, glyphX, glyphY, rowCount, colCount, false));
scatterBlockToFrameBuffer(frameBuffer, pack, colCount, displayHeight - 1 - screenXBase - glyphX, -1,
phyBitPos, pixelState, displayHeight, widthBytes);
}
}
break;
}
case GfxRenderer::PortraitInverted: {
for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) {
const int rowCount = std::min(8, glyphHeight - glyphY);
const int phyBitPos = displayWidth - 1 - screenYBase - (glyphY + rowCount - 1);
if (phyBitPos + rowCount <= 0 || phyBitPos >= displayWidth) continue;
for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) {
const int colCount = std::min(8, glyphWidth - glyphX);
const uint64_t pack =
transpose8x8(extractGlyphBlock(bitmap, glyphWidth, glyphX, glyphY, rowCount, colCount, true));
scatterBlockToFrameBuffer(frameBuffer, pack, colCount, screenXBase + glyphX, 1, phyBitPos, pixelState,
displayHeight, widthBytes);
}
}
break;
}
}
}
// 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);
}
// Compute the runtime drawMask for a given render mode and text darkness.
// Bit N set ⇒ draw when raw 2-bit font value == N
// (raw: 0=white, 1=light gray, 2=dark gray, 3=black).
//
// BW always draws every non-white pixel (darkness has no effect).
// For grayscale modes, increasing darkness folds more AA shades into the
// "draw" set so text becomes progressively bolder. The default darkness=1
// keeps the historical behavior (MSB pass draws both AA shades, LSB pass
// draws only the dark AA shade).
//
// At "Maximum" (darkness>=3) the grayscale passes are suppressed entirely
// (drawMask 0x00). The BW pass already writes raw {1,2,3} as solid black,
// so AA pixels render as hard black with no gray-LUT softening — visibly
// darker than darkness=2 because the gray waveform is skipped.
//
// darkness | GRAYSCALE_MSB | GRAYSCALE_LSB
// --------- ------------------------- -------------------------
// 0 | 0x02 (raw {1}) | 0x04 (raw {2})
// 1 | 0x06 (raw {1,2}) ←dflt | 0x04 (raw {2}) ←dflt
// 2 | 0x06 (raw {1,2}) | 0x06 (raw {1,2})
// 3+ | 0x00 (none) | 0x00 (none)
//
// ─── Worked example ────────────────────────────────────────────────────────
// Imagine a 2-bit antialiased glyph for the diagonal stroke of a letter 'A'.
// Each cell holds the raw font value at that pixel:
//
// raw values . . . 2 3 legend:
// . . 2 3 1 . = 0 (white, never drawn)
// . 2 3 1 . 1 = light gray AA
// 2 3 1 . . 2 = dark gray AA
// 3 1 . . . 3 = solid black (stroke core)
//
// Three render passes write to three independent planes; the panel's
// grayscale waveform combines the BW plane with (MSB,LSB) into 4 shades:
//
// (MSB, LSB) → panel shade
// (0,0) → white
// (1,0) → light gray
// (0,1) → dark gray
// (1,1) → black
//
// Per-pixel result for each darkness level (●=black, ▓=dark gray,
// ░=light gray, ·=white):
//
// darkness=0 Normal — true 4-level AA
// . . . ▓ ● raw=1 → (1,0) light gray
// . . ▓ ● ░ raw=2 → (0,1) dark gray
// . ▓ ● ░ . raw=3 → BW black
// ▓ ● ░ . . Crisp edges, lightest stroke. Best for thin/serif fonts.
// ● ░ . . .
//
// darkness=1 Dark — historical default
// . . . ● ● raw=1 → (1,0) light gray (unchanged)
// . . ● ● ░ raw=2 → (1,1) black (was dark gray)
// . ● ● ░ . Dark-gray fringe collapses to black; light fringe
// ● ● ░ . . survives. Stroke core thickens by ~1px on the
// ● ░ . . . steep side of the slope.
//
// darkness=2 Extra Dark — both AA shades go black
// . . . ● ● raw=1 → (1,1) black
// . . ● ● ● raw=2 → (1,1) black
// . ● ● ● . All AA pixels are pushed to "black" in the gray
// ● ● ● . . plane. The gray waveform still runs, so pixels
// ● ● . . . share the gray-pass voltage profile (slightly
// softer than Maximum).
//
// darkness=3 Maximum — grayscale pass skipped entirely
// . . . ● ● Both grayscale drawMasks are 0x00; nothing is
// . . ● ● ● written to the (MSB,LSB) planes. The BW pass —
// . ● ● ● . which already writes raw {1,2,3} as solid black —
// ● ● ● . . is the only pass the panel sees, refreshed with
// ● ● . . . the hard FAST waveform. Visually identical pixel
// footprint to darkness=2 but driven harder, so
// strokes look noticeably bolder/blacker on the
// physical e-ink panel.
// ───────────────────────────────────────────────────────────────────────────
static inline uint8_t drawMaskFor2BitMode(const GfxRenderer::RenderMode mode, const uint8_t darkness) {
if (mode == GfxRenderer::BW) return 0x0E; // draw raw {1,2,3}
if (darkness >= 3) return 0x00; // skip grayscale entirely (Maximum)
if (mode == GfxRenderer::GRAYSCALE_MSB) {
return (darkness == 0) ? 0x02 : 0x06;
}
// GRAYSCALE_LSB
return (darkness >= 2) ? 0x06 : 0x04;
}
// 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.
//
// Templated on the drawMask byte (a non-type template parameter) so each render-mode/darkness
// combination compiles to its own specialization with the mask folded into a constant.
template <uint8_t drawMask>
static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int rowStartPixel, const int glyphXStartOrEnd,
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.
uint8_t mask = 0;
for (int i = 0; i < count; i++) {
const int logicalX = reverseXInChunk ? (glyphXStartOrEnd - i) : (glyphXStartOrEnd + i);
const uint8_t raw = get2BitPixel(bitmap, rowStartPixel + logicalX);
if ((drawMask >> raw) & 0x01) mask |= static_cast<uint8_t>(1u << (7 - i));
}
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 drawMask the draw decision collapses to a two-bit boolean:
// 0x0E (raw ∈ {1,2,3}): msb | lsb
// 0x06 (raw ∈ {1,2}): msb ^ lsb
// 0x04 (raw == 2): msb & ~lsb
// 0x02 (raw == 1): ~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 03) and b1 (pixels 47) 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 <uint8_t drawMask>
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 (drawMask == 0x0E) { // BW: raw ∈ {1,2,3}
draw0 = msb0 | lsb0;
draw1 = msb1 | lsb1;
} else if constexpr (drawMask == 0x06) { // raw ∈ {1,2}
draw0 = msb0 ^ lsb0;
draw1 = msb1 ^ lsb1;
} else if constexpr (drawMask == 0x04) { // raw == 2 (dark gray)
draw0 = msb0 & ~lsb0;
draw1 = msb1 & ~lsb1;
} else { // drawMask == 0x02, raw == 1 (light gray)
static_assert(drawMask == 0x02, "unsupported drawMask in build2BitRowMaskFromTwoBytes");
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 <uint8_t drawMask>
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) {
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<uint8_t>(1u << (7 - i));
}
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.
// `frameBuffer` may be a strip scratch covering only rows [fbOriginY, fbOriginY+fbRows);
// the writer subtracts fbOriginY when indexing and drops rows outside the band.
// In non-strip mode the caller passes fbOriginY=0, fbRows=displayHeight, so the
// translation is a no-op and the existing absolute-row indexing is preserved.
template <uint8_t drawMask, bool inverted>
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, const int displayWidth, const int displayHeight,
const int widthBytes, const int fbOriginY, const int fbRows) {
for (int glyphX = 0; glyphX < glyphWidth; glyphX++) {
const int phyY = inverted ? (screenXBase + glyphX) : (displayHeight - 1 - (screenXBase + glyphX));
if (phyY < 0 || phyY >= displayHeight) continue;
const int rowY = phyY - fbOriginY;
if (static_cast<unsigned>(rowY) >= static_cast<unsigned>(fbRows)) continue;
uint8_t* const row = frameBuffer + rowY * widthBytes;
for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) {
const int count = std::min(8, glyphHeight - glyphY);
const uint8_t mask = build2BitColMask<drawMask>(bitmap, glyphWidth, glyphX, glyphY, count, inverted);
if (mask == 0) continue;
const int phyBitPos = inverted ? (displayWidth - 1 - screenYBase - (glyphY + count - 1)) : (screenYBase + glyphY);
if (phyBitPos + count <= 0 || phyBitPos >= displayWidth) continue;
writeRowBits(row, phyBitPos, mask, writeState, widthBytes);
}
}
}
template <uint8_t drawMask>
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 int displayWidth, const int displayHeight, const int widthBytes,
const int fbOriginY, const int fbRows) {
// 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.
// BW (drawMask 0x0E) honors the caller's pixelState; grayscale passes always clear the bit.
//
// Tiled grayscale: `frameBuffer` may be a strip scratch with origin fbOriginY
// and fbRows; we subtract the origin when indexing and clip rows outside the
// band. The unsigned compare drops both off-band rows (strip mode) and any
// out-of-frame row (full-frame mode) in one branch.
const bool writeState = (drawMask == 0x0E) ? pixelState : false;
switch (orientation) {
case GfxRenderer::LandscapeCounterClockwise: {
for (int glyphY = 0; glyphY < glyphHeight; glyphY++) {
const int phyY = screenYBase + glyphY;
const int rowY = phyY - fbOriginY;
if (static_cast<unsigned>(rowY) >= static_cast<unsigned>(fbRows)) continue;
uint8_t* const row = frameBuffer + rowY * widthBytes;
const int rowStartPixel = glyphY * glyphWidth;
for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) {
const int count = std::min(8, glyphWidth - glyphX);
const int pixelStart = rowStartPixel + glyphX;
uint8_t mask;
if (count == 8 && (pixelStart & 3) == 0) {
const int srcByteIdx = pixelStart >> 2;
mask = build2BitRowMaskFromTwoBytes<drawMask>(bitmap[srcByteIdx], bitmap[srcByteIdx + 1]);
} else {
mask = build2BitRowMask<drawMask>(bitmap, rowStartPixel, glyphX, count, false);
}
if (mask == 0) continue;
const int phyBitPos = screenXBase + glyphX;
if (phyBitPos + count <= 0 || phyBitPos >= displayWidth) continue;
writeRowBits(row, phyBitPos, mask, writeState, widthBytes);
}
}
break;
}
case GfxRenderer::LandscapeClockwise: {
// Row-outer/chunk-inner: framebuffer rows are written at stride widthBytes
// (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 = displayHeight - 1 - (screenYBase + glyphY);
const int rowY = phyY - fbOriginY;
if (static_cast<unsigned>(rowY) >= static_cast<unsigned>(fbRows)) continue;
uint8_t* const row = frameBuffer + rowY * widthBytes;
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 int pixelStart = rowStartPixel + chunkStart;
uint8_t mask;
if (count == 8 && (pixelStart & 3) == 0) {
const int srcByteIdx = pixelStart >> 2;
mask = reverseBits8(build2BitRowMaskFromTwoBytes<drawMask>(bitmap[srcByteIdx], bitmap[srcByteIdx + 1]));
} else {
mask = build2BitRowMask<drawMask>(bitmap, rowStartPixel, chunkEnd, count, true);
}
if (mask == 0) continue;
const int phyBitPos = displayWidth - 1 - screenXBase - chunkEnd;
if (phyBitPos + count <= 0 || phyBitPos >= displayWidth) continue;
writeRowBits(row, phyBitPos, mask, writeState, widthBytes);
}
}
break;
}
case GfxRenderer::Portrait:
renderGlyphFast2BitPortrait<drawMask, false>(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase,
screenYBase, writeState, displayWidth, displayHeight, widthBytes,
fbOriginY, fbRows);
break;
case GfxRenderer::PortraitInverted:
renderGlyphFast2BitPortrait<drawMask, true>(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase,
screenYBase, writeState, displayWidth, displayHeight, widthBytes,
fbOriginY, fbRows);
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 <TextRotation rotation>
static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode renderMode,
const EpdFontFamily& fontFamily, const uint32_t cp, int cursorX, int cursorY,
const bool pixelState, const EpdFontFamily::Style style) {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
if (!glyph) {
LOG_ERR("GFX", "No glyph for codepoint %d", cp);
return;
}
const EpdFontData* fontData = fontFamily.getData(style);
const bool is2Bit = fontData->is2Bit;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const int top = glyph->top;
// Tiled-grayscale band culling: if this glyph's physical y-extent is entirely
// outside the active strip, skip it before the expensive bitmap decode. This
// is what makes per-band re-rendering cheap. No-op outside strip mode.
if constexpr (rotation == TextRotation::Rotated90CW) {
const int ob = cursorX + fontData->ascender - top;
const int ib = cursorY - left;
if (!renderer.glyphIntersectsStrip(ob, ib - (width - 1), ob + height - 1, ib)) {
return;
}
} else {
const int gx0 = cursorX + left;
const int gy0 = cursorY - top;
if (!renderer.glyphIntersectsStrip(gx0, gy0, gx0 + width - 1, gy0 + height - 1)) {
return;
}
}
const uint8_t* bitmap = renderer.getGlyphBitmap(fontData, glyph);
if (bitmap != nullptr) {
// For Normal: outer loop advances screenY, inner loop advances screenX
// For Rotated: outer loop advances screenX, inner loop advances screenY (in reverse)
int outerBase, innerBase;
if constexpr (rotation == TextRotation::Rotated90CW) {
outerBase = cursorX + fontData->ascender - top; // screenX = outerBase + glyphY
innerBase = cursorY - left; // screenY = innerBase - glyphX
} else {
outerBase = cursorY - top; // screenY = outerBase + glyphY
innerBase = cursorX + left; // screenX = innerBase + glyphX
}
if (is2Bit) {
// Compute the drawMask once per glyph from the current render mode + text-darkness setting.
// The fast path dispatches on this at runtime to a template specialization so the mask is
// a compile-time constant inside the inner loops.
const uint8_t drawMask = drawMaskFor2BitMode(renderMode, renderer.getTextDarkness());
// drawMask == 0 means "draw nothing" — used by Maximum darkness to skip grayscale passes.
if (drawMask == 0) return;
if constexpr (rotation == TextRotation::None) {
// Fast path for normal text orientation. Handles all device orientations via renderGlyphFast2Bit.
// Strip-aware: getWriteTarget() returns the band scratch when a strip is active, otherwise
// the live framebuffer; the (fbOriginY, fbRows) pair tells the writer how to translate phyY
// and clip rows outside the band.
uint8_t* const fb = renderer.getWriteTarget();
const int fbOriginY = renderer.getWriteOriginY();
const int fbRows = renderer.getWriteRows();
switch (drawMask) {
case 0x0E: // BW
renderGlyphFast2Bit<0x0E>(fb, bitmap, width, height, innerBase, outerBase, pixelState,
renderer.getOrientation(), renderer.getDisplayWidth(),
renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows);
break;
case 0x06: // raw {1,2}
renderGlyphFast2Bit<0x06>(fb, bitmap, width, height, innerBase, outerBase, pixelState,
renderer.getOrientation(), renderer.getDisplayWidth(),
renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows);
break;
case 0x04: // raw {2}
renderGlyphFast2Bit<0x04>(fb, bitmap, width, height, innerBase, outerBase, pixelState,
renderer.getOrientation(), renderer.getDisplayWidth(),
renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows);
break;
case 0x02: // raw {1}
renderGlyphFast2Bit<0x02>(fb, bitmap, width, height, innerBase, outerBase, pixelState,
renderer.getOrientation(), renderer.getDisplayWidth(),
renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows);
break;
}
return;
}
// Rotated text fallback: per-pixel path. Uses the same drawMask as the fast path so darkness
// takes effect uniformly. (Previously this branch had a separate X4-only "draw light gray too"
// quirk; that quirk is now subsumed by the default darkness=1 mask, which already includes
// both AA shades for the MSB pass.)
const bool isBW = (drawMask == 0x0E);
int pixelPosition = 0;
for (int glyphY = 0; glyphY < height; glyphY++) {
const int outerCoord = outerBase + glyphY;
for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) {
int screenX, screenY;
if constexpr (rotation == TextRotation::Rotated90CW) {
screenX = outerCoord;
screenY = innerBase - glyphX;
} else {
screenX = innerBase + glyphX;
screenY = outerCoord;
}
const uint8_t byte = bitmap[pixelPosition >> 2];
const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2;
// raw value straight from the font: 0=white, 1=light gray, 2=dark gray, 3=black
const uint8_t raw = (byte >> bit_index) & 0x3;
if ((drawMask >> raw) & 0x01) {
// BW honors caller's pixelState; grayscale passes always clear the bit (false)
renderer.drawPixel(screenX, screenY, isBW ? pixelState : false);
}
}
}
} else {
// Fast path: 1-bit BW mode, non-rotated text — byte-level framebuffer writes, no drawPixel() per pixel.
// renderGlyphFastBW is NOT strip-aware (no fbOriginY/fbRows in its signature) and would
// mis-index into the strip scratch as if it were the full framebuffer. Today no caller
// activates a strip in BW mode, but route to the per-pixel fallback (drawPixel is
// strip-aware) if that ever changes so we never hand a strip buffer to the fast helper.
if constexpr (rotation == TextRotation::None) {
if (renderMode == GfxRenderer::BW && !renderer.isStripActive()) {
renderGlyphFastBW(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState,
renderer.getOrientation(), renderer.getDisplayWidth(), renderer.getDisplayHeight(),
renderer.getDisplayWidthBytes());
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;
for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) {
int screenX, screenY;
if constexpr (rotation == TextRotation::Rotated90CW) {
screenX = outerCoord;
screenY = innerBase - glyphX;
} else {
screenX = innerBase + glyphX;
screenY = outerCoord;
}
const uint8_t byte = bitmap[pixelPosition >> 3];
const uint8_t bit_index = 7 - (pixelPosition & 7);
if ((byte >> bit_index) & 1) {
renderer.drawPixel(screenX, screenY, pixelState);
}
}
}
}
}
}
// Render a glyph at 50% scale via nearest-neighbor sampling. Used for SUP/SUB style bits.
//
// Nearest-neighbor is chosen deliberately: at 50% every source pixel maps cleanly to one
// destination pixel (srcX = dstX*2, srcY = dstY*2), so there is no blending and no new
// gray levels are introduced — important for 1-bit BW rendering.
//
// For 2-bit (anti-aliased) fonts only raw values >= 2 (dark-gray and black) are drawn.
// Dropping the light-gray level keeps small glyphs crisp rather than muddy.
//
// The advance width is also halved in drawText() so layout reserves exactly the right
// horizontal space for the scaled glyph.
static void renderCharScaled(const GfxRenderer& renderer, GfxRenderer::RenderMode renderMode,
const EpdFontFamily& fontFamily, const uint32_t cp, int cursorX, int cursorY,
const bool pixelState, const EpdFontFamily::Style style) {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
if (!glyph) return;
const EpdFontData* fontData = fontFamily.getData(style);
const uint8_t* bitmap = renderer.getGlyphBitmap(fontData, glyph);
if (!bitmap) return;
const int srcW = glyph->width;
const int srcH = glyph->height;
const int dstW = (srcW + 1) / 2; // ceil so odd-width glyphs aren't clipped
const int dstH = (srcH + 1) / 2;
// Scale the glyph bearing by the same factor so the scaled glyph sits at the correct
// pixel offset from the (already-shifted) cursor position.
const int baseX = cursorX + glyph->left / 2;
const int baseY = cursorY - glyph->top / 2;
if (fontData->is2Bit) {
// 2-bit packed format: 4 pixels per byte, MSB first, 2 bits per pixel.
// raw value: 0=white, 1=light-gray, 2=dark-gray, 3=black.
for (int dstY = 0; dstY < dstH; dstY++) {
const int srcY = dstY * 2;
for (int dstX = 0; dstX < dstW; dstX++) {
const int srcX = dstX * 2;
const int pos = srcY * srcW + srcX;
const uint8_t byte = bitmap[pos >> 2];
const uint8_t raw = (byte >> ((3 - (pos & 3)) * 2)) & 0x3;
if (raw >= 2) { // threshold: skip light-gray, draw dark-gray and black
renderer.drawPixel(baseX + dstX, baseY + dstY, pixelState);
}
}
}
} else {
// 1-bit packed format: 8 pixels per byte, MSB first.
for (int dstY = 0; dstY < dstH; dstY++) {
const int srcY = dstY * 2;
for (int dstX = 0; dstX < dstW; dstX++) {
const int srcX = dstX * 2;
const int pos = srcY * srcW + srcX;
const uint8_t byte = bitmap[pos >> 3];
const uint8_t bit = 7 - (pos & 7);
if ((byte >> bit) & 1) {
renderer.drawPixel(baseX + dstX, baseY + dstY, pixelState);
}
}
}
}
}
// IMPORTANT: This function is in critical rendering path and is called for every pixel. Please keep it as simple and
// efficient as possible.
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
int phyX = 0;
int phyY = 0;
const int displayWidth = getDisplayWidth();
const int displayHeight = getDisplayHeight();
// Note: this call should be inlined for better performance
rotateCoordinates(getOrientation(), x, y, &phyX, &phyY, displayWidth, displayHeight);
// Bounds checking against runtime panel dimensions
if (phyX < 0 || phyX >= displayWidth || phyY < 0 || phyY >= displayHeight) {
LOG_ERR("GFX", "!! Outside range (%d, %d) -> (%d, %d)", x, y, phyX, phyY);
return;
}
// Tiled grayscale: redirect writes to the strip scratch and clip to the
// current band. Single predictable branch on the hot per-pixel path.
uint8_t* target = frameBuffer;
uint32_t rowY = static_cast<uint32_t>(phyY);
if (stripActive_) {
if (phyY < stripY0_ || phyY >= stripY0_ + stripRows_) {
return; // pixel outside the band currently being rendered
}
target = stripBuf_;
rowY = static_cast<uint32_t>(phyY - stripY0_);
}
// Calculate byte position and bit position
const uint32_t byteIndex = rowY * getDisplayWidthBytes() + (phyX / 8);
const uint8_t bitPosition = 7 - (phyX % 8); // MSB first
if (state) {
target[byteIndex] &= ~(1 << bitPosition); // Clear bit
} else {
target[byteIndex] |= 1 << bitPosition; // Set bit
}
}
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
}
if (fontCacheManager_ && fontCacheManager_->isScanning()) {
fontCacheManager_->recordText(text, fontId, style);
return 0;
}
int w = 0, h = 0;
fontIt->second.getTextDimensions(text, &w, &h, style);
return w;
}
void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
const int x = (getScreenWidth() - getTextWidth(fontId, text, style)) / 2;
drawText(fontId, x, y, text, black, style);
}
void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
const int yPos = y + getFontAscenderSize(fontId);
int lastBaseX = x;
int lastBaseLeft = 0;
int lastBaseWidth = 0;
int lastBaseTop = 0;
int lastBaseAdvanceFP = 0; // 12.4 fixed-point
int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap
// cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') {
return;
}
if (fontCacheManager_ && fontCacheManager_->isScanning()) {
fontCacheManager_->recordText(text, fontId, style);
return;
}
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return;
}
const auto& font = fontIt->second;
const auto renderModeSnapshot = getRenderMode();
uint32_t cp;
uint32_t prevCp = 0;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp)) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) continue;
const int raiseBy = combiningMark::raiseAboveBase(combiningGlyph->top, combiningGlyph->height, lastBaseTop);
const int combiningX = combiningMark::centerOver(lastBaseX, lastBaseLeft, lastBaseWidth, combiningGlyph->left,
combiningGlyph->width);
renderCharImpl<TextRotation::None>(*this, renderModeSnapshot, font, cp, combiningX, yPos - raiseBy, black, style);
continue;
}
cp = font.applyLigatures(cp, text, style);
// Differential rounding: snap (previous advance + current kern) as one unit so
// identical character pairs always produce the same pixel step regardless of
// where they fall on the line.
if (prevCp != 0) {
const auto kernFP = font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern
lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP); // snap 12.4 fixed-point to nearest pixel
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
lastBaseX += fp4::toPixel(prevAdvanceFP);
prevCp = 0;
prevAdvanceFP = 0;
lastBaseLeft = 0;
lastBaseWidth = 0;
lastBaseTop = 0;
lastBaseAdvanceFP = 0;
continue;
}
lastBaseLeft = glyph->left;
lastBaseWidth = glyph->width;
lastBaseTop = glyph->top;
lastBaseAdvanceFP = glyph->advanceX;
const bool isSupSub = (style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0;
if (isSupSub) {
// Halve the advance so the cursor advances by the same amount the scaled glyph
// actually occupies, keeping spacing correct without needing a separate smaller font.
lastBaseAdvanceFP = (lastBaseAdvanceFP + 1) / 2;
}
prevAdvanceFP = lastBaseAdvanceFP;
if (isSupSub) {
// yPos already carries the vertical offset applied by TextBlock::render().
renderCharScaled(*this, renderModeSnapshot, font, cp, lastBaseX, yPos, black, style);
} else {
renderCharImpl<TextRotation::None>(*this, renderModeSnapshot, font, cp, lastBaseX, yPos, black, style);
}
prevCp = cp;
}
}
void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const {
if (fontCacheManager_ && fontCacheManager_->isScanning()) return;
const int displayWidth = getDisplayWidth();
const int displayHeight = getDisplayHeight();
if (x1 == x2) {
if (y2 < y1) {
std::swap(y1, y2);
}
// In Portrait/PortraitInverted a logical vertical line maps to a physical horizontal span.
switch (getOrientation()) {
case Portrait:
fillPhysicalHSpan(displayHeight - 1 - x1, y1, y2, state);
return;
case PortraitInverted:
fillPhysicalHSpan(x1, displayWidth - 1 - y2, displayWidth - 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);
}
// In Landscape a logical horizontal line maps to a physical horizontal span.
switch (getOrientation()) {
case LandscapeCounterClockwise:
fillPhysicalHSpan(y1, x1, x2, state);
return;
case LandscapeClockwise:
fillPhysicalHSpan(displayHeight - 1 - y1, displayWidth - 1 - x2, displayWidth - 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
int dx = x2 - x1;
int dy = y2 - y1;
int sx = (dx > 0) ? 1 : -1;
int sy = (dy > 0) ? 1 : -1;
dx = sx * dx; // abs
dy = sy * dy; // abs
int err = dx - dy;
while (true) {
drawPixel(x1, y1, state);
if (x1 == x2 && y1 == y2) break;
int e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x1 += sx;
}
if (e2 < dx) {
err += dx;
y1 += sy;
}
}
}
}
void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const int lineWidth, const bool state) const {
for (int i = 0; i < lineWidth; i++) {
drawLine(x1, y1 + i, x2, y2 + i, state);
}
}
void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const bool state) const {
drawLine(x, y, x + width - 1, y, state);
drawLine(x + width - 1, y, x + width - 1, y + height - 1, state);
drawLine(x + width - 1, y + height - 1, x, y + height - 1, state);
drawLine(x, y, x, y + height - 1, state);
}
// Border is inside the rectangle
void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const int lineWidth,
const bool state) const {
for (int i = 0; i < lineWidth; i++) {
drawLine(x + i, y + i, x + width - i, y + i, state);
drawLine(x + width - i, y + i, x + width - i, y + height - i, state);
drawLine(x + width - i, y + height - i, x + i, y + height - i, state);
drawLine(x + i, y + height - i, x + i, y + i, state);
}
}
void GfxRenderer::drawArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir,
const int lineWidth, const bool state) const {
const int stroke = std::min(lineWidth, maxRadius);
const int innerRadius = std::max(maxRadius - stroke, 0);
const int outerRadius = maxRadius;
if (outerRadius <= 0) {
return;
}
const int outerRadiusSq = outerRadius * outerRadius;
const int innerRadiusSq = innerRadius * innerRadius;
int xOuter = outerRadius;
int xInner = innerRadius;
for (int dy = 0; dy <= outerRadius; ++dy) {
while (xOuter > 0 && (xOuter * xOuter + dy * dy) > outerRadiusSq) {
--xOuter;
}
// Keep the smallest x that still lies outside/at the inner radius,
// i.e. (x^2 + y^2) >= innerRadiusSq.
while (xInner > 0 && ((xInner - 1) * (xInner - 1) + dy * dy) >= innerRadiusSq) {
--xInner;
}
if (xOuter < xInner) {
continue;
}
const int x0 = cx + xDir * xInner;
const int x1 = cx + xDir * xOuter;
const int left = std::min(x0, x1);
const int width = std::abs(x1 - x0) + 1;
const int py = cy + yDir * dy;
if (width > 0) {
fillRect(left, py, width, 1, state);
}
}
};
// Border is inside the rectangle, rounded corners
void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth,
const int cornerRadius, bool state) const {
drawRoundedRect(x, y, width, height, lineWidth, cornerRadius, true, true, true, true, state);
}
// Border is inside the rectangle, rounded corners
void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth,
const int cornerRadius, bool roundTopLeft, bool roundTopRight, bool roundBottomLeft,
bool roundBottomRight, bool state) const {
if (lineWidth <= 0 || width <= 0 || height <= 0) {
return;
}
const int maxRadius = std::min({cornerRadius, width / 2, height / 2});
if (maxRadius <= 0) {
drawRect(x, y, width, height, lineWidth, state);
return;
}
const int stroke = std::min(lineWidth, maxRadius);
const int right = x + width - 1;
const int bottom = y + height - 1;
const int horizontalWidth = width - 2 * maxRadius;
if (horizontalWidth > 0) {
if (roundTopLeft || roundTopRight) {
fillRect(x + maxRadius, y, horizontalWidth, stroke, state);
}
if (roundBottomLeft || roundBottomRight) {
fillRect(x + maxRadius, bottom - stroke + 1, horizontalWidth, stroke, state);
}
}
const int verticalHeight = height - 2 * maxRadius;
if (verticalHeight > 0) {
if (roundTopLeft || roundBottomLeft) {
fillRect(x, y + maxRadius, stroke, verticalHeight, state);
}
if (roundTopRight || roundBottomRight) {
fillRect(right - stroke + 1, y + maxRadius, stroke, verticalHeight, state);
}
}
if (roundTopLeft) {
drawArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, lineWidth, state);
}
if (roundTopRight) {
drawArc(maxRadius, right - maxRadius, y + maxRadius, 1, -1, lineWidth, state);
}
if (roundBottomRight) {
drawArc(maxRadius, right - maxRadius, bottom - maxRadius, 1, 1, lineWidth, state);
}
if (roundBottomLeft) {
drawArc(maxRadius, x + maxRadius, bottom - maxRadius, -1, 1, lineWidth, state);
}
}
// 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)getDisplayWidth() - 1);
if (cX0 > cX1 || phyY < 0 || phyY >= (int)getDisplayHeight()) return;
// Tiled grayscale: redirect to the strip scratch and drop rows outside the
// active band. Off-band rows return cheaply before any bit-fiddling.
uint8_t* target = frameBuffer;
int rowY = phyY;
if (stripActive_) {
if (phyY < stripY0_ || phyY >= stripY0_ + stripRows_) return;
target = stripBuf_;
rowY = phyY - stripY0_;
}
uint8_t* const row = target + rowY * getDisplayWidthBytes();
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 fillMask = (0xFF >> leftBits) & ~(0xFF >> (rightBits + 1));
row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask);
return;
}
// Left partial byte
if (leftBits != 0) {
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, patternByte, fullEnd - fullStart + 1);
}
// Right partial byte
if (rightBits != 7) {
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;
const int displayWidth = getDisplayWidth();
const int displayHeight = getDisplayHeight();
// 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 (getOrientation()) {
case Portrait:
// Logical column x → physical row (displayHeight-1-x); logical y range → physical x span
for (int lx = x; lx < x + width; lx++) {
fillPhysicalHSpan(displayHeight - 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, displayWidth - 1 - (y + height - 1), displayWidth - 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 (displayHeight-1-y); logical x range → physical x span (mirrored)
for (int ly = y; ly < y + height; ly++) {
fillPhysicalHSpan(displayHeight - 1 - ly, displayWidth - 1 - (x + width - 1), displayWidth - 1 - x, state);
}
return;
}
}
// NOTE: Those are in critical path, and need to be templated to avoid runtime checks for every pixel.
// Any branching must be done outside the loops to avoid performance degradation.
template <>
void GfxRenderer::drawPixelDither<Color::Clear>(const int x, const int y) const {
// Do nothing
}
template <>
void GfxRenderer::drawPixelDither<Color::Black>(const int x, const int y) const {
drawPixel(x, y, true);
}
template <>
void GfxRenderer::drawPixelDither<Color::White>(const int x, const int y) const {
drawPixel(x, y, false);
}
template <>
void GfxRenderer::drawPixelDither<Color::LightGray>(const int x, const int y) const {
drawPixel(x, y, x % 2 == 0 && y % 2 == 0);
}
template <>
void GfxRenderer::drawPixelDither<Color::DarkGray>(const int x, const int y) const {
drawPixel(x, y, (x + y) % 2 == 0); // TODO: maybe find a better pattern?
}
void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const {
if (color == Color::Clear) {
} else if (color == Color::Black) {
fillRect(x, y, width, height, true);
} else if (color == Color::White) {
fillRect(x, y, width, height, false);
} else if (color == Color::DarkGray) {
// Pattern: dark where (phyX + phyY) % 2 == 0 (alternating checkerboard).
// Byte patterns (phyY even / phyY odd):
// Portrait / PortraitInverted: 0xAA / 0x55
// LandscapeCW / LandscapeCCW: 0x55 / 0xAA
switch (getOrientation()) {
case Portrait:
for (int lx = x; lx < x + width; lx++) {
const int phyY = getDisplayHeight() - 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, getDisplayWidth() - 1 - (y + height - 1), getDisplayWidth() - 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 = getDisplayHeight() - 1 - ly;
const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA;
fillPhysicalHSpanByte(phyY, getDisplayWidth() - 1 - (x + width - 1), getDisplayWidth() - 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 (getOrientation()) {
case Portrait:
for (int lx = x; lx < x + width; lx++) {
const int phyY = getDisplayHeight() - 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, getDisplayWidth() - 1 - (y + height - 1), getDisplayWidth() - 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 = getDisplayHeight() - 1 - ly;
if (phyY % 2 == 0) continue; // all-white row
fillPhysicalHSpanByte(phyY, getDisplayWidth() - 1 - (x + width - 1), getDisplayWidth() - 1 - x, 0xAA);
}
return;
}
}
}
template <Color color>
void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir) const {
if (maxRadius <= 0) return;
if constexpr (color == Color::Clear) {
return;
}
const int radiusSq = maxRadius * maxRadius;
// Avoid sqrt by scanning from outer radius inward while y grows.
int x = maxRadius;
for (int dy = 0; dy <= maxRadius; ++dy) {
while (x > 0 && (x * x + dy * dy) > radiusSq) {
--x;
}
if (x < 0) break;
const int py = cy + yDir * dy;
if (py < 0 || py >= getScreenHeight()) continue;
int x0 = cx;
int x1 = cx + xDir * x;
if (x0 > x1) std::swap(x0, x1);
const int width = x1 - x0 + 1;
if (width <= 0) continue;
if constexpr (color == Color::Black) {
fillRect(x0, py, width, 1, true);
} else if constexpr (color == Color::White) {
fillRect(x0, py, width, 1, false);
} else {
// LightGray / DarkGray: use existing dithered fill path.
fillRectDither(x0, py, width, 1, color);
}
}
}
void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, const int height, const int cornerRadius,
const Color color) const {
fillRoundedRect(x, y, width, height, cornerRadius, true, true, true, true, color);
}
void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, const int height, const int cornerRadius,
bool roundTopLeft, bool roundTopRight, bool roundBottomLeft, bool roundBottomRight,
const Color color) const {
if (width <= 0 || height <= 0) {
return;
}
// Assume if we're not rounding all corners then we are only rounding one side
const int roundedSides = (!roundTopLeft || !roundTopRight || !roundBottomLeft || !roundBottomRight) ? 1 : 2;
const int maxRadius = std::min({cornerRadius, width / roundedSides, height / roundedSides});
if (maxRadius <= 0) {
fillRectDither(x, y, width, height, color);
return;
}
const int horizontalWidth = width - 2 * maxRadius;
if (horizontalWidth > 0) {
fillRectDither(x + maxRadius + 1, y, horizontalWidth - 2, height, color);
}
const int leftFillTop = y + (roundTopLeft ? (maxRadius + 1) : 0);
const int leftFillBottom = y + height - 1 - (roundBottomLeft ? (maxRadius + 1) : 0);
if (leftFillBottom >= leftFillTop) {
fillRectDither(x, leftFillTop, maxRadius + 1, leftFillBottom - leftFillTop + 1, color);
}
const int rightFillTop = y + (roundTopRight ? (maxRadius + 1) : 0);
const int rightFillBottom = y + height - 1 - (roundBottomRight ? (maxRadius + 1) : 0);
if (rightFillBottom >= rightFillTop) {
fillRectDither(x + width - maxRadius - 1, rightFillTop, maxRadius + 1, rightFillBottom - rightFillTop + 1, color);
}
auto fillArcTemplated = [this](int maxRadius, int cx, int cy, int xDir, int yDir, Color color) {
switch (color) {
case Color::Clear:
break;
case Color::Black:
fillArc<Color::Black>(maxRadius, cx, cy, xDir, yDir);
break;
case Color::White:
fillArc<Color::White>(maxRadius, cx, cy, xDir, yDir);
break;
case Color::LightGray:
fillArc<Color::LightGray>(maxRadius, cx, cy, xDir, yDir);
break;
case Color::DarkGray:
fillArc<Color::DarkGray>(maxRadius, cx, cy, xDir, yDir);
break;
}
};
if (roundTopLeft) {
fillArcTemplated(maxRadius, x + maxRadius, y + maxRadius, -1, -1, color);
}
if (roundTopRight) {
fillArcTemplated(maxRadius, x + width - maxRadius - 1, y + maxRadius, 1, -1, color);
}
if (roundBottomRight) {
fillArcTemplated(maxRadius, x + width - maxRadius - 1, y + height - maxRadius - 1, 1, 1, color);
}
if (roundBottomLeft) {
fillArcTemplated(maxRadius, x + maxRadius, y + height - maxRadius - 1, -1, 1, color);
}
}
void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
const auto currentOrientation = getOrientation();
int rotatedX = 0;
int rotatedY = 0;
rotateCoordinates(currentOrientation, x, y, &rotatedX, &rotatedY, getDisplayWidth(), getDisplayHeight());
// Rotate origin corner
switch (currentOrientation) {
case Portrait:
rotatedY = rotatedY - height;
break;
case PortraitInverted:
rotatedX = rotatedX - width;
break;
case LandscapeClockwise:
rotatedY = rotatedY - height;
rotatedX = rotatedX - width;
break;
case LandscapeCounterClockwise:
break;
}
// TODO: Rotate bits
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
}
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
display.drawImageTransparent(bitmap, y, getScreenWidth() - width - x, height, width);
}
void GfxRenderer::drawIconInverted(const uint8_t bitmap[], const int x, const int y, const int width,
const int height) const {
// Portrait-mode coordinate transform (x↔y swap), matching drawIcon.
// OR with ~srcByte sets framebuffer bits to 1 (white) wherever the icon
// bitmap is 0 (black) — produces a white icon on a black background.
const int physX = y;
const int physY = getScreenWidth() - width - x;
const int imgW = height; // dimensions swapped by portrait transform
const int imgH = width;
const int srcStride = (imgW + 7) / 8;
if (physX + imgW <= 0 || physX >= static_cast<int>(panelWidthBytes) * 8) return;
if (physY + imgH <= 0 || physY >= static_cast<int>(panelHeight)) return;
const int baseByte = (physX >= 0) ? (physX >> 3) : -(((-physX) + 7) >> 3);
const int bitShift = ((physX % 8) + 8) % 8;
const int trail = srcStride * 8 - imgW;
const uint8_t trailMask = static_cast<uint8_t>(0xFF << trail);
const int lastCol = srcStride - 1;
for (int row = 0; row < imgH; ++row) {
const int destY = physY + row;
if (destY < 0 || destY >= static_cast<int>(panelHeight)) continue;
const int rowBase = destY * static_cast<int>(panelWidthBytes);
const int srcOffset = row * srcStride;
if (bitShift == 0) {
for (int col = 0; col < srcStride; ++col) {
const int dst = baseByte + col;
if (dst < 0) continue;
if (dst >= static_cast<int>(panelWidthBytes)) break;
uint8_t inv = ~bitmap[srcOffset + col];
if (col == lastCol && trail > 0) inv &= trailMask;
frameBuffer[rowBase + dst] |= inv;
}
} else {
const int rsh = bitShift;
const int lsh = 8 - bitShift;
for (int col = 0; col < srcStride; ++col) {
uint8_t inv = ~bitmap[srcOffset + col];
if (col == lastCol && trail > 0) inv &= trailMask;
const int dstHi = baseByte + col;
const int dstLo = dstHi + 1;
if (dstHi >= 0 && dstHi < static_cast<int>(panelWidthBytes)) {
frameBuffer[rowBase + dstHi] |= static_cast<uint8_t>(inv >> rsh);
}
if (dstLo >= 0 && dstLo < static_cast<int>(panelWidthBytes)) {
frameBuffer[rowBase + dstLo] |= static_cast<uint8_t>(inv << lsh);
}
}
}
}
}
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
const float cropX, const float cropY) const {
if (fontCacheManager_ && fontCacheManager_->isScanning()) return;
// For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit)
if (bitmap.is1Bit() && cropX == 0.0f && cropY == 0.0f) {
drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight);
return;
}
float scale = 1.0f;
bool isScaled = false;
int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f);
int cropPixY = std::floor(bitmap.getHeight() * cropY / 2.0f);
const float croppedWidth = (1.0f - cropX) * static_cast<float>(bitmap.getWidth());
const float croppedHeight = (1.0f - cropY) * static_cast<float>(bitmap.getHeight());
bool hasTargetBounds = false;
float fitScale = 1.0f;
if (maxWidth > 0 && croppedWidth > 0.0f) {
fitScale = static_cast<float>(maxWidth) / croppedWidth;
hasTargetBounds = true;
}
if (maxHeight > 0 && croppedHeight > 0.0f) {
const float heightScale = static_cast<float>(maxHeight) / croppedHeight;
fitScale = hasTargetBounds ? std::min(fitScale, heightScale) : heightScale;
hasTargetBounds = true;
}
if (hasTargetBounds && fitScale < 1.0f) {
scale = fitScale;
isScaled = true;
}
// Calculate output row size (2 bits per pixel, packed into bytes)
// IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) {
LOG_ERR("GFX", "!! Failed to allocate BMP row buffers");
free(outputRow);
free(rowBytes);
return;
}
const auto renderModeSnapshot = getRenderMode();
for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) {
// The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative).
// Screen's (0, 0) is the top-left corner.
int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
if (isScaled) {
screenY = std::floor(screenY * scale);
}
screenY += y; // the offset should not be scaled
if (screenY >= getScreenHeight()) {
break;
}
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
LOG_ERR("GFX", "Failed to read row %d from bitmap", bmpY);
free(outputRow);
free(rowBytes);
return;
}
if (screenY < 0) {
continue;
}
if (bmpY < cropPixY) {
// Skip the row if it's outside the crop area
continue;
}
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
int screenX = bmpX - cropPixX;
if (isScaled) {
screenX = std::floor(screenX * scale);
}
screenX += x; // the offset should not be scaled
if (screenX >= getScreenWidth()) {
break;
}
if (screenX < 0) {
continue;
}
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
if (renderModeSnapshot == BW && val < 3) {
drawPixel(screenX, screenY);
} else if (renderModeSnapshot == GRAYSCALE_MSB && (val == 1 || val == 2)) {
drawPixel(screenX, screenY, false);
} else if (renderModeSnapshot == GRAYSCALE_LSB && val == 1) {
drawPixel(screenX, screenY, false);
}
}
}
free(outputRow);
free(rowBytes);
}
void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
const int maxHeight) const {
float scale = 1.0f;
bool isScaled = false;
if (maxWidth > 0) {
const float s = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
if (s != 1.0f) {
scale = s;
isScaled = true;
}
}
if (maxHeight > 0) {
const float s = static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight());
if (s < scale || (scale == 1.0f && s != 1.0f)) {
scale = s;
isScaled = (scale != 1.0f);
}
}
// For 1-bit BMP, output is still 2-bit packed (for consistency with readNextRow)
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) {
LOG_ERR("GFX", "!! Failed to allocate 1-bit BMP row buffers");
free(outputRow);
free(rowBytes);
return;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
// Read rows sequentially using readNextRow
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
LOG_ERR("GFX", "Failed to read row %d from 1-bit bitmap", bmpY);
free(outputRow);
free(rowBytes);
return;
}
// Calculate screen Y based on whether BMP is top-down or bottom-up
const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
int screenY = y + (isScaled ? static_cast<int>(std::floor(bmpYOffset * scale)) : bmpYOffset);
if (screenY >= getScreenHeight()) {
continue; // Continue reading to keep row counter in sync
}
if (screenY < 0) {
continue;
}
for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) {
int screenX = x + (isScaled ? static_cast<int>(std::floor(bmpX * scale)) : bmpX);
if (screenX >= getScreenWidth()) {
break;
}
if (screenX < 0) {
continue;
}
// Get 2-bit value (result of readNextRow quantization)
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
// For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3)
// val < 3 means black pixel (draw it)
if (val < 3) {
drawPixel(screenX, screenY, true);
}
// White pixels (val == 3) are not drawn (leave background)
}
}
free(outputRow);
free(rowBytes);
}
void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const {
if (numPoints < 3) return;
// Find bounding box
int minY = yPoints[0], maxY = yPoints[0];
for (int i = 1; i < numPoints; i++) {
if (yPoints[i] < minY) minY = yPoints[i];
if (yPoints[i] > maxY) maxY = yPoints[i];
}
// Clip to screen
if (minY < 0) minY = 0;
if (maxY >= getScreenHeight()) maxY = getScreenHeight() - 1;
// Allocate node buffer for scanline algorithm
auto* nodeX = static_cast<int*>(malloc(numPoints * sizeof(int)));
if (!nodeX) {
LOG_ERR("GFX", "!! Failed to allocate polygon node buffer");
return;
}
// Scanline fill algorithm
for (int scanY = minY; scanY <= maxY; scanY++) {
int nodes = 0;
// Find all intersection points with edges
int j = numPoints - 1;
for (int i = 0; i < numPoints; i++) {
if ((yPoints[i] < scanY && yPoints[j] >= scanY) || (yPoints[j] < scanY && yPoints[i] >= scanY)) {
// Calculate X intersection using fixed-point to avoid float
int dy = yPoints[j] - yPoints[i];
if (dy != 0) {
nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) * (xPoints[j] - xPoints[i]) / dy;
}
}
j = i;
}
// Sort nodes by X
std::sort(nodeX, nodeX + nodes);
// Fill between pairs of nodes
for (int i = 0; i < nodes - 1; i += 2) {
int startX = nodeX[i];
int endX = nodeX[i + 1];
// Clip to screen
if (startX < 0) startX = 0;
if (endX >= getScreenWidth()) endX = getScreenWidth() - 1;
// Draw horizontal line
for (int x = startX; x <= endX; x++) {
drawPixel(x, scanY, state);
}
}
}
free(nodeX);
}
// For performance measurement (using static to allow "const" methods)
static unsigned long start_ms = 0;
static bool start_ms_valid = false;
void GfxRenderer::clearScreen(const uint8_t color) const {
start_ms = millis();
start_ms_valid = true;
if (stripActive_) {
// Clear only the active band's scratch, not the shared framebuffer.
memset(stripBuf_, color, static_cast<size_t>(panelWidthBytes) * stripRows_);
return;
}
display.clearScreen(color);
}
void GfxRenderer::beginStripTarget(uint8_t* scratch, int stripY0, int stripRows) const {
// Band is caller-guaranteed in-bounds (the reader's grayscale loop computes
// it); assert catches future misuse in debug before it mis-renders.
assert(scratch != nullptr && stripRows > 0 && stripY0 >= 0 && stripY0 <= static_cast<int>(panelHeight) - stripRows);
stripBuf_ = scratch;
stripY0_ = stripY0;
stripRows_ = stripRows;
stripActive_ = true;
// Latch the orientation→phyY linear coefficients used by glyphIntersectsStrip()
// so the cull is one multiply-add per bbox corner instead of a switch.
// Derived from rotateCoordinates() with only the y-output retained.
switch (getOrientation()) {
case Portrait:
stripPhyYStepX_ = -1;
stripPhyYStepY_ = 0;
stripPhyYBase_ = panelHeight - 1;
break;
case LandscapeClockwise:
stripPhyYStepX_ = 0;
stripPhyYStepY_ = -1;
stripPhyYBase_ = panelHeight - 1;
break;
case PortraitInverted:
stripPhyYStepX_ = 1;
stripPhyYStepY_ = 0;
stripPhyYBase_ = 0;
break;
case LandscapeCounterClockwise:
stripPhyYStepX_ = 0;
stripPhyYStepY_ = 1;
stripPhyYBase_ = 0;
break;
}
}
void GfxRenderer::endStripTarget() const {
stripActive_ = false;
stripBuf_ = nullptr;
stripY0_ = 0;
stripRows_ = 0;
}
bool GfxRenderer::acquireStripScratch() {
if (stripScratch_) return true;
if (panelWidthBytes == 0 || panelHeight == 0) {
LOG_ERR("GFX", "acquireStripScratch called before begin()");
return false;
}
int rows = STRIP_SCRATCH_TARGET_BYTES / panelWidthBytes;
if (rows < 1) rows = 1;
if (rows > static_cast<int>(panelHeight)) rows = panelHeight;
const size_t bytes = static_cast<size_t>(panelWidthBytes) * rows;
stripScratch_ = static_cast<uint8_t*>(heap_caps_malloc(bytes, MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT));
if (!stripScratch_) {
LOG_INF("GFX", "Strip scratch alloc failed (%zu bytes)", bytes);
return false;
}
stripScratchRows_ = rows;
return true;
}
void GfxRenderer::releaseStripScratch() {
if (!stripScratch_) return;
heap_caps_free(stripScratch_);
stripScratch_ = nullptr;
stripScratchRows_ = 0;
}
bool GfxRenderer::glyphIntersectsStrip(int x0, int y0, int x1, int y1) const {
if (!stripActive_) {
return true;
}
// Use the precomputed (stepX, stepY, base) latched in beginStripTarget() so
// each call is two multiply-adds + a range check, no rotateCoordinates
// switch. The four 90-degree orientations all reduce to "phyY depends on
// exactly one of (x, y)" — exactly one of stepX/stepY is non-zero — so phyY
// is monotonic across the bbox and the two opposite-corner phyY values
// bracket the full physical y-extent.
const int ay = stripPhyYStepX_ * x0 + stripPhyYStepY_ * y0 + stripPhyYBase_;
const int by = stripPhyYStepX_ * x1 + stripPhyYStepY_ * y1 + stripPhyYBase_;
const int minY = ay < by ? ay : by;
const int maxY = ay > by ? ay : by;
return !(maxY < stripY0_ || minY >= stripY0_ + stripRows_);
}
void GfxRenderer::writeGrayscalePlaneStrip(bool lsbPlane, const uint8_t* scratch, int yStart, int numRows) const {
// Guard the uint16_t casts below: a negative would wrap to a huge length.
assert(yStart >= 0 && numRows > 0 && yStart <= static_cast<int>(panelHeight) - numRows);
display.writeGrayscalePlaneStrip(lsbPlane, scratch, static_cast<uint16_t>(yStart), static_cast<uint16_t>(numRows));
}
bool GfxRenderer::supportsStripGrayscale() const { return display.supportsStripGrayscale(); }
void GfxRenderer::invertScreen() const {
for (uint32_t i = 0; i < frameBufferSize; i++) {
frameBuffer[i] = ~frameBuffer[i];
}
}
static constexpr unsigned int encodeRefreshMode(const HalDisplay::RefreshMode mode) {
return static_cast<unsigned int>(mode) + 1u;
}
static constexpr HalDisplay::RefreshMode decodeRefreshMode(const unsigned int value) {
return static_cast<HalDisplay::RefreshMode>(value - 1u);
}
void GfxRenderer::setNextDisplayRefreshMode(const HalDisplay::RefreshMode refreshMode) const {
refreshOverride.store(encodeRefreshMode(refreshMode), std::memory_order_release);
}
void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const {
auto effectiveMode = refreshMode;
unsigned int overrideValue = refreshOverride.load(std::memory_order_acquire);
if (overrideValue != REFRESH_OVERRIDE_NONE) {
unsigned int expected = overrideValue;
if (refreshOverride.compare_exchange_strong(expected, REFRESH_OVERRIDE_NONE, std::memory_order_acq_rel,
std::memory_order_acquire)) {
effectiveMode = decodeRefreshMode(overrideValue);
} else if (expected != REFRESH_OVERRIDE_NONE) {
effectiveMode = decodeRefreshMode(expected);
refreshOverride.store(REFRESH_OVERRIDE_NONE, std::memory_order_release);
}
}
if (start_ms_valid) {
auto elapsed = millis() - start_ms;
LOG_DBG("GFX", "Time = %lu ms from clearScreen to displayBuffer", elapsed);
} else {
LOG_DBG("GFX", "Time = n/a from clearScreen to displayBuffer (no clearScreen marker)");
}
start_ms_valid = false;
display.displayBuffer(effectiveMode, fadingFix.load(std::memory_order_relaxed));
}
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
const EpdFontFamily::Style style) const {
if (!text || maxWidth <= 0) return "";
std::string item = text;
// U+2026 HORIZONTAL ELLIPSIS (UTF-8: 0xE2 0x80 0xA6)
const char* ellipsis = "\xe2\x80\xa6";
int textWidth = getTextWidth(fontId, item.c_str(), style);
if (textWidth <= maxWidth) {
// Text fits, return as is
return item;
}
while (!item.empty() && getTextWidth(fontId, (item + ellipsis).c_str(), style) >= maxWidth) {
utf8RemoveLastChar(item);
}
return item.empty() ? ellipsis : item + ellipsis;
}
std::vector<std::string> GfxRenderer::wrappedText(const int fontId, const char* text, const int maxWidth,
const int maxLines, const EpdFontFamily::Style style) const {
std::vector<std::string> lines;
if (!text || maxWidth <= 0 || maxLines <= 0) return lines;
std::string remaining = text;
std::string currentLine;
while (!remaining.empty()) {
if (static_cast<int>(lines.size()) == maxLines - 1) {
// Last available line: combine any word already started on this line with
// the rest of the text, then let truncatedText fit it with an ellipsis.
std::string lastContent = currentLine.empty() ? remaining : currentLine + " " + remaining;
lines.push_back(truncatedText(fontId, lastContent.c_str(), maxWidth, style));
return lines;
}
// Find next word
size_t spacePos = remaining.find(' ');
std::string word;
if (spacePos == std::string::npos) {
word = remaining;
remaining.clear();
} else {
word = remaining.substr(0, spacePos);
remaining.erase(0, spacePos + 1);
}
std::string testLine = currentLine.empty() ? word : currentLine + " " + word;
if (getTextWidth(fontId, testLine.c_str(), style) <= maxWidth) {
currentLine = testLine;
} else {
if (!currentLine.empty()) {
lines.push_back(currentLine);
// If the carried-over word itself exceeds maxWidth, truncate it and
// push it as a complete line immediately — storing it in currentLine
// would allow a subsequent short word to be appended after the ellipsis.
if (getTextWidth(fontId, word.c_str(), style) > maxWidth) {
lines.push_back(truncatedText(fontId, word.c_str(), maxWidth, style));
currentLine.clear();
if (static_cast<int>(lines.size()) >= maxLines) return lines;
} else {
currentLine = word;
}
} else {
// Single word wider than maxWidth: truncate and stop to avoid complicated
// splitting rules (different between languages). Results in an aesthetically
// pleasing end.
lines.push_back(truncatedText(fontId, word.c_str(), maxWidth, style));
return lines;
}
}
}
if (!currentLine.empty() && static_cast<int>(lines.size()) < maxLines) {
lines.push_back(currentLine);
}
return lines;
}
// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation
int GfxRenderer::getScreenWidth() const {
switch (getOrientation()) {
case Portrait:
case PortraitInverted:
// 480px wide in portrait logical coordinates
return panelHeight;
case LandscapeClockwise:
case LandscapeCounterClockwise:
// 800px wide in landscape logical coordinates
return panelWidth;
}
return panelHeight;
}
int GfxRenderer::getScreenHeight() const {
switch (getOrientation()) {
case Portrait:
case PortraitInverted:
// 800px tall in portrait logical coordinates
return panelWidth;
case LandscapeClockwise:
case LandscapeCounterClockwise:
// 480px tall in landscape logical coordinates
return panelHeight;
}
return panelWidth;
}
static bool logicalRectToPhysicalBounds(GfxRenderer::Orientation orientation, int lx, int ly, int lw, int lh,
uint16_t panelWidth, uint16_t panelHeight, int* outX0, int* outY0, int* outX1,
int* outY1) {
if (lw <= 0 || lh <= 0) return false;
int minX = INT32_MAX, minY = INT32_MAX, maxX = INT32_MIN, maxY = INT32_MIN;
const int corners[4][2] = {{lx, ly}, {lx + lw - 1, ly}, {lx, ly + lh - 1}, {lx + lw - 1, ly + lh - 1}};
for (auto& c : corners) {
int phyX, phyY;
rotateCoordinates(orientation, c[0], c[1], &phyX, &phyY, panelWidth, panelHeight);
if (phyX < minX) minX = phyX;
if (phyY < minY) minY = phyY;
if (phyX > maxX) maxX = phyX;
if (phyY > maxY) maxY = phyY;
}
if (minX < 0) minX = 0;
if (minY < 0) minY = 0;
if (maxX >= panelWidth) maxX = panelWidth - 1;
if (maxY >= panelHeight) maxY = panelHeight - 1;
if (minX > maxX || minY > maxY) return false;
*outX0 = minX;
*outY0 = minY;
*outX1 = maxX;
*outY1 = maxY;
return true;
}
size_t GfxRenderer::getRegionByteSize(int lx, int ly, int lw, int lh) const {
int x0, y0, x1, y1;
if (!logicalRectToPhysicalBounds(getOrientation(), lx, ly, lw, lh, panelWidth, panelHeight, &x0, &y0, &x1, &y1))
return 0;
const int byteX0 = x0 / 8;
const int byteX1 = x1 / 8;
return static_cast<size_t>(byteX1 - byteX0 + 1) * static_cast<size_t>(y1 - y0 + 1);
}
bool GfxRenderer::copyRegionToBuffer(int lx, int ly, int lw, int lh, uint8_t* buf, size_t bufSize) const {
int x0, y0, x1, y1;
if (!logicalRectToPhysicalBounds(getOrientation(), lx, ly, lw, lh, panelWidth, panelHeight, &x0, &y0, &x1, &y1))
return false;
const int byteX0 = x0 / 8;
const int bytesPerRow = x1 / 8 - byteX0 + 1;
const int rowCount = y1 - y0 + 1;
const size_t needed = static_cast<size_t>(bytesPerRow) * static_cast<size_t>(rowCount);
if (bufSize < needed || !frameBuffer || !buf) return false;
for (int row = 0; row < rowCount; row++)
memcpy(buf + row * bytesPerRow, frameBuffer + (y0 + row) * panelWidthBytes + byteX0, bytesPerRow);
return true;
}
bool GfxRenderer::copyBufferToRegion(int lx, int ly, int lw, int lh, const uint8_t* buf, size_t bufSize) const {
int x0, y0, x1, y1;
if (!logicalRectToPhysicalBounds(getOrientation(), lx, ly, lw, lh, panelWidth, panelHeight, &x0, &y0, &x1, &y1))
return false;
const int byteX0 = x0 / 8;
const int bytesPerRow = x1 / 8 - byteX0 + 1;
const int rowCount = y1 - y0 + 1;
const size_t needed = static_cast<size_t>(bytesPerRow) * static_cast<size_t>(rowCount);
if (bufSize < needed || !frameBuffer || !buf) return false;
for (int row = 0; row < rowCount; row++)
memcpy(frameBuffer + (y0 + row) * panelWidthBytes + byteX0, buf + row * bytesPerRow, bytesPerRow);
return true;
}
int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style style) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
}
const EpdGlyph* spaceGlyph = fontIt->second.getGlyph(' ', style);
return spaceGlyph ? fp4::toPixel(spaceGlyph->advanceX) : 0; // snap 12.4 fixed-point to nearest pixel
}
int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
const EpdFontFamily::Style style) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) return 0;
const auto& font = fontIt->second;
const EpdGlyph* spaceGlyph = font.getGlyph(' ', style);
const int32_t spaceAdvanceFP = spaceGlyph ? static_cast<int32_t>(spaceGlyph->advanceX) : 0;
// Combine space advance + flanking kern into one fixed-point sum before snapping.
// Snapping the combined value avoids the +/-1 px error from snapping each component separately.
const int32_t kernFP = static_cast<int32_t>(font.getKerning(leftCp, ' ', style)) +
static_cast<int32_t>(font.getKerning(' ', rightCp, style));
return fp4::toPixel(spaceAdvanceFP + kernFP);
}
int GfxRenderer::getKerning(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
const EpdFontFamily::Style style) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) return 0;
const int kernFP = fontIt->second.getKerning(leftCp, rightCp, style); // 4.4 fixed-point
return fp4::toPixel(kernFP); // snap 4.4 fixed-point to nearest pixel
}
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFamily::Style style) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
}
if (fontCacheManager_ && fontCacheManager_->isScanning()) {
fontCacheManager_->recordText(text, fontId, style);
return 0;
}
uint32_t cp;
uint32_t prevCp = 0;
int widthPx = 0;
int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap
const auto& font = fontIt->second;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp)) {
continue;
}
cp = font.applyLigatures(cp, text, style);
// Differential rounding: snap (previous advance + current kern) together,
// matching drawText so measurement and rendering agree exactly.
if (prevCp != 0) {
const auto kernFP = font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern
widthPx += fp4::toPixel(prevAdvanceFP + kernFP); // snap 12.4 fixed-point to nearest pixel
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
widthPx += fp4::toPixel(prevAdvanceFP);
prevCp = 0;
prevAdvanceFP = 0;
continue;
}
prevAdvanceFP = glyph->advanceX;
if ((style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
prevAdvanceFP = (prevAdvanceFP + 1) / 2;
}
prevCp = cp;
}
widthPx += fp4::toPixel(prevAdvanceFP); // final glyph's advance
return widthPx;
}
int GfxRenderer::getFontAscenderSize(const int fontId) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
}
return fontIt->second.getData(EpdFontFamily::REGULAR)->ascender;
}
int GfxRenderer::getLineHeight(const int fontId) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
}
return fontIt->second.getData(EpdFontFamily::REGULAR)->advanceY;
}
int GfxRenderer::getTextHeight(const int fontId) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
}
return fontIt->second.getData(EpdFontFamily::REGULAR)->ascender;
}
void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
// Cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') {
return;
}
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return;
}
if (fontCacheManager_ && fontCacheManager_->isScanning()) {
fontCacheManager_->recordText(text, fontId, style);
return;
}
const auto& font = fontIt->second;
int lastBaseY = y;
int lastBaseLeft = 0;
int lastBaseWidth = 0;
int lastBaseTop = 0;
int lastBaseAdvanceFP = 0; // 12.4 fixed-point
int32_t prevAdvanceFP = 0; // 12.4 fixed-point: prev glyph's advance + next kern for snap
uint32_t cp;
uint32_t prevCp = 0;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp)) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) continue;
const int raiseBy = combiningMark::raiseAboveBase(combiningGlyph->top, combiningGlyph->height, lastBaseTop);
const int combiningX = x - raiseBy;
const int combiningY = combiningMark::centerOverRotated90CW(lastBaseY, lastBaseLeft, lastBaseWidth,
combiningGlyph->left, combiningGlyph->width);
renderCharImpl<TextRotation::Rotated90CW>(*this, getRenderMode(), font, cp, combiningX, combiningY, black, style);
continue;
}
cp = font.applyLigatures(cp, text, style);
// Differential rounding: snap (previous advance + current kern) as one unit,
// subtracting for the rotated coordinate direction.
if (prevCp != 0) {
const auto kernFP = font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern
lastBaseY -= fp4::toPixel(prevAdvanceFP + kernFP); // snap 12.4 fixed-point to nearest pixel
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
lastBaseY -= fp4::toPixel(prevAdvanceFP);
prevCp = 0;
prevAdvanceFP = 0;
lastBaseLeft = 0;
lastBaseWidth = 0;
lastBaseTop = 0;
lastBaseAdvanceFP = 0;
continue;
}
lastBaseLeft = glyph->left;
lastBaseWidth = glyph->width;
lastBaseTop = glyph->top;
lastBaseAdvanceFP = glyph->advanceX;
prevAdvanceFP = lastBaseAdvanceFP;
renderCharImpl<TextRotation::Rotated90CW>(*this, getRenderMode(), font, cp, x, lastBaseY, black, style);
prevCp = cp;
}
}
uint8_t* GfxRenderer::getFrameBuffer() const { return frameBuffer; }
size_t GfxRenderer::getBufferSize() const { return frameBufferSize; }
// unused
// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); }
void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(frameBuffer); }
void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(frameBuffer); }
void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(fadingFix); }
void GfxRenderer::freeBwBufferChunks() {
for (auto& bwBufferChunk : bwBufferChunks) {
if (bwBufferChunk) {
free(bwBufferChunk);
bwBufferChunk = nullptr;
}
}
}
/**
* This should be called before grayscale buffers are populated.
* A `restoreBwBuffer` call should always follow the grayscale render if this method was called.
* Uses chunked allocation to avoid needing 48KB of contiguous memory.
* Returns true if buffer was stored successfully, false if allocation failed.
*/
bool GfxRenderer::storeBwBuffer() { return storeBwBufferRect(0, 0, getScreenWidth(), getScreenHeight()); }
bool GfxRenderer::storeBwBufferRect(const int x, const int y, const int width, const int height) {
if (width <= 0 || height <= 0) {
freeBwBufferChunks();
bwSnapshotRowStart = 0;
bwSnapshotRowEnd = 0;
bwSnapshotSizeBytes = 0;
LOG_ERR("GFX", "!! BW buffer store rect invalid: x=%d y=%d w=%d h=%d", x, y, width, height);
return false;
}
const int screenWidth = getScreenWidth();
const int screenHeight = getScreenHeight();
if (screenWidth <= 0 || screenHeight <= 0 || panelWidthBytes == 0 || panelHeight == 0 || !frameBuffer) {
freeBwBufferChunks();
bwSnapshotRowStart = 0;
bwSnapshotRowEnd = 0;
bwSnapshotSizeBytes = 0;
LOG_ERR("GFX", "!! BW buffer store unavailable (screen=%dx%d panelHeight=%u rowBytes=%u fb=%p)", screenWidth,
screenHeight, panelHeight, panelWidthBytes, frameBuffer);
return false;
}
const int clampedX0 = std::max(0, x);
const int clampedY0 = std::max(0, y);
const int clampedX1 = std::min(screenWidth - 1, x + width - 1);
const int clampedY1 = std::min(screenHeight - 1, y + height - 1);
if (clampedX0 > clampedX1 || clampedY0 > clampedY1) {
freeBwBufferChunks();
bwSnapshotRowStart = 0;
bwSnapshotRowEnd = 0;
bwSnapshotSizeBytes = 0;
LOG_ERR("GFX", "!! BW buffer store rect outside screen: x=%d y=%d w=%d h=%d", x, y, width, height);
return false;
}
int rowStart = 0;
int rowEnd = 0;
switch (getOrientation()) {
case LandscapeCounterClockwise:
rowStart = clampedY0;
rowEnd = clampedY1;
break;
case LandscapeClockwise:
rowStart = static_cast<int>(panelHeight) - 1 - clampedY1;
rowEnd = static_cast<int>(panelHeight) - 1 - clampedY0;
break;
case Portrait:
rowStart = static_cast<int>(panelHeight) - 1 - clampedX1;
rowEnd = static_cast<int>(panelHeight) - 1 - clampedX0;
break;
case PortraitInverted:
rowStart = clampedX0;
rowEnd = clampedX1;
break;
}
rowStart = std::max(0, rowStart);
rowEnd = std::min(static_cast<int>(panelHeight) - 1, rowEnd);
if (rowStart > rowEnd) {
freeBwBufferChunks();
bwSnapshotRowStart = 0;
bwSnapshotRowEnd = 0;
bwSnapshotSizeBytes = 0;
LOG_ERR("GFX", "!! BW buffer store row-band invalid after orientation mapping: rows=%d..%d", rowStart, rowEnd);
return false;
}
const size_t rows = static_cast<size_t>(rowEnd - rowStart + 1);
const size_t snapshotSizeBytes = rows * panelWidthBytes;
const size_t snapshotBaseOffset = static_cast<size_t>(rowStart) * panelWidthBytes;
if (snapshotSizeBytes == 0 || snapshotBaseOffset + snapshotSizeBytes > frameBufferSize) {
LOG_ERR("GFX", "!! BW buffer store row-band out of bounds: base=%zu size=%zu frame=%u", snapshotBaseOffset,
snapshotSizeBytes, frameBufferSize);
return false;
}
freeBwBufferChunks();
bwSnapshotRowStart = static_cast<uint16_t>(rowStart);
bwSnapshotRowEnd = static_cast<uint16_t>(rowEnd);
bwSnapshotSizeBytes = snapshotSizeBytes;
auto attemptStore = [&](size_t chunkSize) {
bwBufferChunks.assign((bwSnapshotSizeBytes + chunkSize - 1) / chunkSize, nullptr);
for (size_t i = 0; i < bwBufferChunks.size(); i++) {
if (bwBufferChunks[i]) {
LOG_ERR("GFX", "!! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk", i);
free(bwBufferChunks[i]);
bwBufferChunks[i] = nullptr;
}
const size_t offset = i * chunkSize;
const size_t allocSize = std::min(chunkSize, bwSnapshotSizeBytes - offset);
bwBufferChunks[i] = static_cast<uint8_t*>(malloc(allocSize));
if (!bwBufferChunks[i]) {
const uint32_t freeHeap = esp_get_free_heap_size();
const uint32_t contigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
LOG_ERR("GFX", "!! Failed to allocate BW buffer chunk %zu (%zu bytes): free=%u contig=%u", i, allocSize,
freeHeap, contigHeap);
freeBwBufferChunks();
return false;
}
memcpy(bwBufferChunks[i], frameBuffer + snapshotBaseOffset + offset, allocSize);
}
bwBufferChunkSize = chunkSize;
LOG_DBG("GFX", "Stored BW buffer rows [%u..%u] (%zu bytes) in %zu chunks (%zu bytes each)", bwSnapshotRowStart,
bwSnapshotRowEnd, bwSnapshotSizeBytes, bwBufferChunks.size(), chunkSize);
return true;
};
if (attemptStore(bwBufferChunkSize)) {
return true;
}
if (bwBufferChunkSize > 4096) {
LOG_INF("GFX", "BW buffer allocation failed with chunk size %zu, retrying with 4096", bwBufferChunkSize);
if (attemptStore(4096)) {
return true;
}
}
if (bwBufferChunkSize > 2048) {
LOG_INF("GFX", "BW buffer allocation still failed, retrying with 2048");
if (attemptStore(2048)) {
return true;
}
}
if (bwBufferChunkSize > 1024) {
LOG_INF("GFX", "BW buffer allocation still failed, retrying with 1024");
if (attemptStore(1024)) {
return true;
}
}
LOG_ERR("GFX", "!! BW buffer storage failed after retrying smaller chunk sizes");
bwSnapshotSizeBytes = 0;
bwSnapshotRowStart = 0;
bwSnapshotRowEnd = 0;
return false;
}
/**
* This can only be called if `storeBwBuffer` was called prior to the grayscale render.
* It should be called to restore the BW buffer state after grayscale rendering is complete.
* Uses chunked restoration to match chunked storage.
*/
void GfxRenderer::restoreBwBuffer() {
if (bwSnapshotSizeBytes == 0) {
display.cleanupGrayscaleBuffers(frameBuffer);
freeBwBufferChunks();
LOG_ERR("GFX", "BW restore skipped: no stored snapshot metadata; cleaned grayscale buffers only");
return;
}
// Check if all chunks are allocated
bool missingChunks = false;
for (const auto& bwBufferChunk : bwBufferChunks) {
if (!bwBufferChunk) {
missingChunks = true;
break;
}
}
if (missingChunks) {
// Store failed part-way (or was skipped), so we cannot restore BW bytes safely.
// Still cleanup grayscale staging buffers to avoid retaining large temporary
// allocations that can later starve TLS handshakes.
display.cleanupGrayscaleBuffers(frameBuffer);
freeBwBufferChunks();
bwSnapshotSizeBytes = 0;
bwSnapshotRowStart = 0;
bwSnapshotRowEnd = 0;
LOG_ERR("GFX", "BW restore skipped due to missing chunks; cleaned grayscale buffers only");
return;
}
const size_t snapshotBaseOffset = static_cast<size_t>(bwSnapshotRowStart) * panelWidthBytes;
for (size_t i = 0; i < bwBufferChunks.size(); i++) {
const size_t offset = i * bwBufferChunkSize;
const size_t chunkSize = std::min(bwBufferChunkSize, bwSnapshotSizeBytes - offset);
memcpy(frameBuffer + snapshotBaseOffset + offset, bwBufferChunks[i], chunkSize);
}
display.cleanupGrayscaleBuffers(frameBuffer);
freeBwBufferChunks();
LOG_DBG("GFX", "Restored BW buffer rows [%u..%u] (%zu bytes) and freed BW chunks", bwSnapshotRowStart,
bwSnapshotRowEnd, bwSnapshotSizeBytes);
bwSnapshotSizeBytes = 0;
bwSnapshotRowStart = 0;
bwSnapshotRowEnd = 0;
}
/**
* Cleanup grayscale buffers using the current frame buffer.
* Use this when BW buffer was re-rendered instead of stored/restored.
*/
void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
if (frameBuffer) {
display.cleanupGrayscaleBuffers(frameBuffer);
}
}
void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const {
switch (getOrientation()) {
case Portrait:
*outTop = VIEWABLE_MARGIN_TOP;
*outRight = VIEWABLE_MARGIN_RIGHT;
*outBottom = VIEWABLE_MARGIN_BOTTOM;
*outLeft = VIEWABLE_MARGIN_LEFT;
break;
case LandscapeClockwise:
*outTop = VIEWABLE_MARGIN_LEFT;
*outRight = VIEWABLE_MARGIN_TOP;
*outBottom = VIEWABLE_MARGIN_RIGHT;
*outLeft = VIEWABLE_MARGIN_BOTTOM;
break;
case PortraitInverted:
*outTop = VIEWABLE_MARGIN_BOTTOM;
*outRight = VIEWABLE_MARGIN_LEFT;
*outBottom = VIEWABLE_MARGIN_TOP;
*outLeft = VIEWABLE_MARGIN_RIGHT;
break;
case LandscapeCounterClockwise:
*outTop = VIEWABLE_MARGIN_RIGHT;
*outRight = VIEWABLE_MARGIN_BOTTOM;
*outBottom = VIEWABLE_MARGIN_LEFT;
*outLeft = VIEWABLE_MARGIN_TOP;
break;
}
}