Compare commits

..
19 changed files with 837 additions and 314 deletions

No files matched your search

+6 -6
View File
@@ -501,7 +501,7 @@ The **Sleep Screen** setting controls what is displayed when the device goes to
| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. |
| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. |
| **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. |
| **Transparent** | A BMP or PNG overlay drawn over the current screen. Supports PNG and 32-bit BGRA alpha transparency, and treats white as transparent in regular BMPs. Falls back to **Dark** if no valid overlay image is found. |
| **Transparent** | A BMP overlay drawn over the current screen. Supports alpha transparency in 32-bit BGRA images and treats white as transparent in regular BMPs. Falls back to **Dark** if no valid overlay image is found. |
| **None** | A blank screen. |
#### Cover settings
@@ -520,17 +520,17 @@ To use custom sleep images, set the sleep screen mode to **Custom** or **Cover +
#### Transparent overlay images
To use transparent sleep overlays, set the sleep screen mode to **Transparent**, then place BMP or PNG files on the SD card:
To use transparent sleep overlays, set the sleep screen mode to **Transparent**, then place BMP files on the SD card:
- **Multiple Images (recommended):** Create a `.sleep-overlay` directory in the root of the SD card and place any number of valid overlay `.bmp` or `.png` images inside. One will be randomly selected each time the device sleeps. A directory named `sleep-overlay` is also accepted as a fallback.
- **Single Image:** Place `sleep-overlay.bmp` or `sleep-overlay.png` in the root directory. A root BMP takes priority over a root PNG, and both take priority over the `.sleep-overlay`/`sleep-overlay` directories.
- **Multiple Images (recommended):** Create a `.sleep-overlay` directory in the root of the SD card and place any number of valid overlay `.bmp` images inside. One will be randomly selected each time the device sleeps. A directory named `sleep-overlay` is also accepted as a fallback.
- **Single Image:** Place a file named `sleep-overlay.bmp` in the root directory. This takes priority over the `.sleep-overlay`/`sleep-overlay` directories.
Transparent overlay files are intentionally separate from normal sleep images. Regular BMP formats supported by CrossPoint are accepted; white pixels leave the existing screen unchanged. For per-pixel alpha transparency, use a PNG with an alpha channel or a 32-bit BGRA BMP with both visible and non-opaque pixels. Opaque white pixels in alpha images erase the content behind them.
Transparent overlay files are intentionally separate from normal sleep images. Regular BMP formats supported by CrossPoint are accepted; white pixels leave the existing screen unchanged. For per-pixel alpha transparency, use a 32-bit BGRA BMP with both visible and non-opaque pixels. Opaque white pixels in these alpha images erase the content behind them.
> [!TIP]
> For best results:
> - For non-transparent **Custom** mode, use uncompressed BMP files with 24-bit color depth.
> - For **Transparent** mode, use a PNG or uncompressed 32-bit BGRA BMP for per-pixel alpha, or a regular BMP for white-as-transparent artwork.
> - For **Transparent** mode, use a regular BMP for white-as-transparent artwork or an uncompressed 32-bit BGRA BMP for per-pixel alpha.
> - X4: Use a resolution of 480x800 pixels to match the device's screen resolution.
> - X3: Use a resolution of 528x792 pixels to match the device's screen resolution.
+3 -3
View File
@@ -144,14 +144,14 @@ struct DirectPixelWriter {
// Write a single 2-bit dithered pixel value to the framebuffer.
// Must be called after beginRow() for the current row.
// No bounds checking — caller guarantees coordinates are valid.
inline void writePixel(int logicalX, uint8_t pixelValue, bool writeWhiteInBw = false) const {
inline void writePixel(int logicalX, uint8_t pixelValue) const {
// Determine whether to draw based on render mode
bool draw;
bool state;
switch (mode) {
case GfxRenderer::BW:
draw = writeWhiteInBw || pixelValue < 3;
state = pixelValue < 3;
draw = (pixelValue < 3);
state = true;
break;
case GfxRenderer::GRAYSCALE_MSB:
draw = (pixelValue == 1 || pixelValue == 2);
@@ -18,9 +18,6 @@ struct RenderConfig {
bool useDithering = true;
bool performanceMode = false;
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
float sourceCropX = 0.0f; // Fraction cropped equally from the left and right edges
float sourceCropY = 0.0f; // Fraction cropped equally from the top and bottom edges
bool preserveAlpha = false; // Skip transparent pixels instead of compositing them against white
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
};
@@ -7,7 +7,6 @@
#include <Memory.h>
#include <PNGdec.h>
#include <algorithm>
#include <cstdlib>
#include <memory>
#include <new>
@@ -22,7 +21,6 @@ namespace {
// The draw callback receives this via pDraw->pUser (set by png.decode()).
// The file I/O callbacks receive the HalFile* via pFile->fHandle (set by pngOpen()).
struct PngContext {
PNG* decoder{nullptr};
GfxRenderer* renderer{nullptr};
const RenderConfig* config{nullptr};
int screenWidth{0};
@@ -32,18 +30,14 @@ struct PngContext {
float scale{1.f};
int srcWidth{0};
int srcHeight{0};
int cropLeft{0};
int cropTop{0};
int visibleWidth{0};
int visibleHeight{0};
int dstWidth{0};
int dstHeight{0};
int lastDstY{-1}; // Track last rendered destination Y to avoid duplicates
PixelCache cache;
bool caching{false};
uint8_t* grayLineBuffer{nullptr};
uint8_t* alphaLineBuffer{nullptr};
};
// File I/O callbacks use pFile->fHandle to access the HalFile*,
@@ -137,31 +131,19 @@ uint8_t expandSampleToByte(uint8_t sample, int bitsPerSample) {
return static_cast<uint8_t>((sample * 255U) / maxSample);
}
uint8_t alphaThreshold4x4(const int x, const int y) {
static constexpr uint8_t BAYER_4X4[16] = {0, 128, 32, 160, 192, 64, 224, 96, 48, 176, 16, 144, 240, 112, 208, 80};
return BAYER_4X4[((y & 0x03) << 2) | (x & 0x03)];
}
// Convert an entire source line to grayscale and optionally retain alpha.
// Convert entire source line to grayscale with alpha blending to white background.
// Low-bit-depth grayscale/indexed scanlines are packed most-significant sample first.
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
void convertLineToGray(const uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, int bitsPerSample,
uint8_t* palette, int hasAlpha, uint32_t transparentColor, uint8_t* alphaLine) {
const auto store = [grayLine, alphaLine](const int x, const uint8_t gray, const uint8_t alpha) {
grayLine[x] = alphaLine ? gray : static_cast<uint8_t>((gray * alpha + 255u * (255u - alpha)) / 255u);
if (alphaLine) alphaLine[x] = alpha;
};
uint8_t* palette, int hasAlpha) {
switch (pixelType) {
case PNG_PIXEL_GRAYSCALE:
if (bitsPerSample == 8 && !hasAlpha && !alphaLine) {
if (bitsPerSample == 8) {
memcpy(grayLine, pPixels, width);
} else {
for (int x = 0; x < width; x++) {
const uint8_t sample = readPackedSample(pPixels, x, bitsPerSample);
const uint8_t gray = expandSampleToByte(sample, bitsPerSample);
store(x, gray, hasAlpha && sample == static_cast<uint8_t>(transparentColor) ? 0 : 255);
grayLine[x] = expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample);
}
}
break;
@@ -169,44 +151,53 @@ void convertLineToGray(const uint8_t* pPixels, uint8_t* grayLine, int width, int
case PNG_PIXEL_TRUECOLOR:
for (int x = 0; x < width; x++) {
const uint8_t* p = &pPixels[x * 3];
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
const uint32_t color = (static_cast<uint32_t>(p[0]) << 16) | (static_cast<uint32_t>(p[1]) << 8) | p[2];
store(x, gray, hasAlpha && color == transparentColor ? 0 : 255);
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
break;
case PNG_PIXEL_INDEXED:
if (palette) {
for (int x = 0; x < width; x++) {
const uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
const uint8_t* p = &palette[idx * 3];
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
store(x, gray, hasAlpha ? palette[768 + idx] : 255);
if (hasAlpha) {
for (int x = 0; x < width; x++) {
uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
uint8_t* p = &palette[idx * 3];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = palette[768 + idx];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
} else {
for (int x = 0; x < width; x++) {
uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
uint8_t* p = &palette[idx * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
}
} else {
for (int x = 0; x < width; x++) {
store(x, expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample), 255);
grayLine[x] = expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample);
}
}
break;
case PNG_PIXEL_GRAY_ALPHA:
for (int x = 0; x < width; x++) {
store(x, pPixels[x * 2], pPixels[x * 2 + 1]);
uint8_t gray = pPixels[x * 2];
uint8_t alpha = pPixels[x * 2 + 1];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
break;
case PNG_PIXEL_TRUECOLOR_ALPHA:
for (int x = 0; x < width; x++) {
const uint8_t* p = &pPixels[x * 4];
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
store(x, gray, p[3]);
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = p[3];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
break;
default:
memset(grayLine, 128, width);
if (alphaLine) memset(alphaLine, 255, width);
break;
}
}
@@ -217,29 +208,25 @@ int pngDrawCallback(PNGDRAW* pDraw) {
int srcY = pDraw->y;
int srcWidth = ctx->srcWidth;
if (srcY < ctx->cropTop || srcY >= ctx->cropTop + ctx->visibleHeight) return 1;
const int visibleSrcY = srcY - ctx->cropTop;
// Map source rows with the exact output-height ratio. During downscaling,
// multiple source rows can select the same output row; during upscaling, one
// source row must be repeated across every output row in its range. Emitting
// only the first row of an upscale leaves zero-filled (black) gaps in the
// streamed pixel cache.
int firstDstY = (visibleSrcY * ctx->dstHeight) / ctx->visibleHeight;
int firstDstY = (srcY * ctx->dstHeight) / ctx->srcHeight;
int endDstY = firstDstY + 1;
if (ctx->dstHeight > ctx->visibleHeight) {
endDstY = ((visibleSrcY + 1) * ctx->dstHeight) / ctx->visibleHeight;
if (ctx->dstHeight > ctx->srcHeight) {
endDstY = ((srcY + 1) * ctx->dstHeight) / ctx->srcHeight;
}
if (firstDstY <= ctx->lastDstY) firstDstY = ctx->lastDstY + 1;
if (firstDstY >= endDstY || firstDstY >= ctx->dstHeight) return 1;
if (endDstY > ctx->dstHeight) endDstY = ctx->dstHeight;
// PNGdec parses tRNS while decoding, after open() returns, so query the
// transparent color here rather than caching its pre-decode value.
const uint32_t transparentColor = ctx->decoder ? ctx->decoder->getTransparentColor() : 0;
// Convert entire source line to grayscale (improves cache locality)
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->iBpp, pDraw->pPalette,
pDraw->iHasAlpha, transparentColor, ctx->alphaLineBuffer);
pDraw->iHasAlpha);
// Render scaled rows using Bresenham-style integer stepping (no floating-point division)
int dstWidth = ctx->dstWidth;
@@ -274,30 +261,27 @@ int pngDrawCallback(PNGDRAW* pDraw) {
}
}
int srcX = ctx->cropLeft;
int srcX = 0;
int error = 0;
for (int dstX = 0; dstX < dstWidth; dstX++) {
int outX = outXBase + dstX;
if (outX < screenWidth) {
const uint8_t alpha = ctx->alphaLineBuffer ? ctx->alphaLineBuffer[srcX] : 255;
if (alpha >= 8 && alpha > alphaThreshold4x4(outX, outY)) {
uint8_t gray = ctx->grayLineBuffer[srcX];
uint8_t gray = ctx->grayLineBuffer[srcX];
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
pw.writePixel(outX, ditheredGray, ctx->alphaLineBuffer != nullptr);
if (caching) cw.writePixel(outX, ditheredGray);
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
pw.writePixel(outX, ditheredGray);
if (caching) cw.writePixel(outX, ditheredGray);
}
// Bresenham-style stepping: advance srcX based on ratio visibleWidth/dstWidth
error += ctx->visibleWidth;
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
error += srcWidth;
while (error >= dstWidth) {
error -= dstWidth;
srcX++;
@@ -356,7 +340,6 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
PngContext ctx;
ctx.decoder = png.get();
ctx.renderer = &renderer;
ctx.config = &config;
ctx.screenWidth = renderer.getScreenWidth();
@@ -377,36 +360,28 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
// Calculate output dimensions
ctx.srcWidth = png->getWidth();
ctx.srcHeight = png->getHeight();
ctx.cropLeft = static_cast<int>(ctx.srcWidth * std::clamp(config.sourceCropX, 0.0f, 0.99f) / 2.0f);
ctx.cropTop = static_cast<int>(ctx.srcHeight * std::clamp(config.sourceCropY, 0.0f, 0.99f) / 2.0f);
ctx.visibleWidth = ctx.srcWidth - 2 * ctx.cropLeft;
ctx.visibleHeight = ctx.srcHeight - 2 * ctx.cropTop;
if (ctx.visibleWidth <= 0 || ctx.visibleHeight <= 0) {
LOG_ERR("PNG", "PNG crop leaves no visible pixels");
return false;
}
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
ctx.dstWidth = config.maxWidth;
ctx.dstHeight = config.maxHeight;
ctx.scale = (float)ctx.dstWidth / ctx.visibleWidth;
ctx.scale = (float)ctx.dstWidth / ctx.srcWidth;
} else {
// Calculate scale factor to fit within maxWidth/maxHeight
float scaleX = (float)config.maxWidth / ctx.visibleWidth;
float scaleY = (float)config.maxHeight / ctx.visibleHeight;
float scaleX = (float)config.maxWidth / ctx.srcWidth;
float scaleY = (float)config.maxHeight / ctx.srcHeight;
ctx.scale = (scaleX < scaleY) ? scaleX : scaleY;
if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale
ctx.dstWidth = (int)(ctx.visibleWidth * ctx.scale);
ctx.dstHeight = (int)(ctx.visibleHeight * ctx.scale);
ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale);
ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale);
}
ctx.lastDstY = -1; // Reset row tracking
const int pixelType = png->getPixelType();
const int bitsPerSample = png->getBpp();
LOG_DBG("PNG", "PNG %dx%d (visible %dx%d) -> %dx%d (scale %.2f), type: %d, bpp: %d", ctx.srcWidth, ctx.srcHeight,
ctx.visibleWidth, ctx.visibleHeight, ctx.dstWidth, ctx.dstHeight, ctx.scale, pixelType, bitsPerSample);
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), type: %d, bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth,
ctx.dstHeight, ctx.scale, pixelType, bitsPerSample);
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType, bitsPerSample);
if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) {
@@ -435,18 +410,12 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
return false;
}
// tRNS chunks are parsed during decode(), so hasAlpha() is not reliable yet
// for color-key and indexed transparency. Reserve the alpha line whenever
// the caller requests preservation; opaque pixels simply store alpha 255.
const bool retainAlpha = config.preserveAlpha;
const size_t lineBufferBytes = grayBufSize * (retainAlpha ? 2u : 1u);
auto lineBuffers = makeUniqueNoThrow<uint8_t[]>(lineBufferBytes);
if (!lineBuffers) {
LOG_ERR("PNG", "Failed to allocate PNG line buffers (%u bytes)", static_cast<unsigned>(lineBufferBytes));
auto grayLineBuffer = makeUniqueNoThrow<uint8_t[]>(grayBufSize);
if (!grayLineBuffer) {
LOG_ERR("PNG", "Failed to allocate gray line buffer");
return false;
}
ctx.grayLineBuffer = lineBuffers.get();
ctx.alphaLineBuffer = retainAlpha ? ctx.grayLineBuffer + grayBufSize : nullptr;
ctx.grayLineBuffer = grayLineBuffer.get();
// Stream the pixel cache to disk. PNGdec delivers source scanlines top to
// bottom and we emit at most one (downscaled) output row per callback, so the
@@ -454,7 +423,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
// unlike the old full-image buffer it neither competes with the ~44KB decoder
// nor forces larger images to skip caching - which previously meant a full
// re-decode on every one of an image page's ~14 render passes.
ctx.caching = !config.preserveAlpha && !config.cachePath.empty();
ctx.caching = !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.begin(config.cachePath, ctx.dstWidth, ctx.dstHeight, config.x, config.y, 1)) {
LOG_ERR("PNG", "Failed to start cache stream, continuing without caching");
@@ -467,7 +436,6 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
unsigned long decodeTime = millis() - decodeStart;
ctx.grayLineBuffer = nullptr;
ctx.alphaLineBuffer = nullptr;
if (rc != PNG_SUCCESS) {
LOG_ERR("PNG", "Decode failed: %d", rc);
+346
View File
@@ -0,0 +1,346 @@
#include "LibraryText.h"
#include <Utf8.h>
#include <algorithm>
#include <array>
#include <cstring>
namespace library {
namespace {
// Letters with no canonical decomposition, plus the punctuation that would
// otherwise become a space and break word shapes. Everything here is a distinct
// letter in Unicode, so no amount of NFD gets us the ASCII form.
struct CharMap {
uint32_t cp;
const char* replacement;
};
constexpr CharMap EXPLICIT_MAP[] = {
{0x00D8, "o"}, // Ø
{0x00F8, "o"}, // ø — "Søren" folds to "soren", not "nesb"
{0x00C6, "ae"}, // Æ
{0x00E6, "ae"}, // æ
{0x0152, "oe"}, // Œ
{0x0153, "oe"}, // œ
{0x00DF, "ss"}, // ß
{0x0141, "l"}, // Ł
{0x0142, "l"}, // ł
{0x00D0, "d"}, // Ð
{0x00F0, "d"}, // ð
{0x0110, "d"}, // Đ
{0x0111, "d"}, // đ
{0x00DE, "th"}, // Þ
{0x00FE, "th"}, // þ
{0x0131, "i"}, // ı
{0x0027, "'"}, // ' — kept, not turned into a space, so "O'Brien" stays one word
{0x2019, "'"}, // ’ — and so the curly form folds to the same thing
{0x2018, "'"}, // ‘
};
const char* explicitMapping(const uint32_t cp) {
for (const auto& e : EXPLICIT_MAP) {
if (e.cp == cp) return e.replacement;
}
return nullptr;
}
// Reverse lookup in the NFC table: given a precomposed codepoint, return the
// base letter it was composed from, or 0.
//
// The table is sorted by (base, mark) so this is a linear scan rather than a
// binary search. It runs once per non-ASCII character at index-build time and
// over a short query per keystroke, never over the whole index, so the scan is
// not on any hot path. Reusing the generated table costs no extra flash — it is
// already linked for utf8ComposeNfc().
// Fully decompose: é -> e, and the rare doubly-accented forms (ế -> ê -> e).
uint32_t stripDiacritics(uint32_t cp) {
for (int guard = 0; guard < 4; guard++) {
const uint32_t base = utf8DecomposedBase(cp);
if (base == 0) break;
cp = base;
}
return cp;
}
bool isAsciiAlnum(const uint32_t cp) {
return (cp >= '0' && cp <= '9') || (cp >= 'a' && cp <= 'z') || (cp >= 'A' && cp <= 'Z');
}
void appendLowerAscii(const uint32_t cp, std::string& out) {
out.push_back(static_cast<char>(cp >= 'A' && cp <= 'Z' ? cp - 'A' + 'a' : cp));
}
// Articles stripped from the head of sort and search keys. Display text never
// goes through this.
constexpr const char* ARTICLES[] = {"the ", "a ", "an ", "le ", "la ", "les ", "l'", "un ",
"une ", "de ", "du ", "des ", "der ", "die ", "das ", "el ",
"los ", "las ", "il ", "lo ", "gli ", "i ", "o ", "os "};
// Views into `folded`, not copies: the caller keeps that string alive for as
// long as the tokens, and a std::string per token costs an allocation each plus
// 24 bytes of stack apiece -- 288 B for the twelve, over the 256 B this repo
// asks callers to justify.
void splitTokens(std::string_view folded, std::string_view* out, size_t maxTokens, size_t& count) {
count = 0;
size_t i = 0;
while (i < folded.size() && count < maxTokens) {
while (i < folded.size() && folded[i] == ' ') i++;
const size_t start = i;
while (i < folded.size() && folded[i] != ' ') i++;
if (i > start) out[count++] = folded.substr(start, i - start);
}
}
} // namespace
std::string fold(const std::string_view text, const bool stripArticle) {
std::string out;
out.reserve(text.size());
const auto* cursor = reinterpret_cast<const unsigned char*>(text.data());
const auto* end = cursor + text.size();
bool pendingSpace = false;
while (cursor < end) {
// The shared decoder stops on NUL and every std::string source is
// NUL-terminated, but fold's contract is string_view — and a view may end
// mid-sequence. Refuse to decode a lead byte whose continuation bytes lie
// past the end rather than trusting whatever sits there.
const unsigned char lead = *cursor;
const ptrdiff_t promised = lead < 0x80 ? 1
: (lead >> 5) == 0x06 ? 2
: (lead >> 4) == 0x0E ? 3
: (lead >> 3) == 0x1E ? 4
: 1;
if (promised > end - cursor) break;
const uint32_t cp = utf8NextCodepoint(&cursor);
if (cp == 0) break;
if (utf8IsCombiningMark(cp)) continue; // already-decomposed input
const char* mapped = explicitMapping(cp);
if (mapped != nullptr) {
if (pendingSpace && !out.empty()) out.push_back(' ');
pendingSpace = false;
out.append(mapped);
continue;
}
const uint32_t base = stripDiacritics(cp);
if (isAsciiAlnum(base)) {
if (pendingSpace && !out.empty()) out.push_back(' ');
pendingSpace = false;
appendLowerAscii(base, out);
continue;
}
// Everything else — punctuation, symbols, unmapped scripts — separates
// words. Deferring the space keeps runs collapsed and drops trailing ones.
if (!out.empty()) pendingSpace = true;
}
if (stripArticle) {
for (const char* article : ARTICLES) {
const size_t len = strlen(article);
if (out.size() > len && out.compare(0, len, article) == 0) {
out.erase(0, len);
break;
}
}
}
return out;
}
std::string cleanPersonName(const std::string_view author) {
std::string out;
out.reserve(author.size());
int depth = 0;
for (size_t i = 0; i < author.size(); i++) {
const char c = author[i];
if (c == '[' || c == '(') {
depth++;
continue;
}
if (c == ']' || c == ')') {
if (depth > 0) depth--;
continue;
}
if (c == ';') break; // secondary authors
if (depth > 0) continue;
if (c == '_') {
// "Herbert G_ Wells" — the underscore stands in for a full stop the
// filesystem would not take. Between letters it is an abbreviation dot;
// at the end of a word it is just noise.
const bool betweenLetters = i > 0 && i + 1 < author.size() &&
isalpha(static_cast<unsigned char>(author[i - 1])) &&
isalpha(static_cast<unsigned char>(author[i + 1]));
out.push_back(betweenLetters ? '.' : ' ');
continue;
}
out.push_back(c);
}
while (!out.empty() &&
(out.back() == ' ' || out.back() == ',' || out.back() == '-' || out.back() == '.' || out.back() == '_')) {
out.pop_back();
}
size_t start = 0;
while (start < out.size() && out[start] == ' ') start++;
out.erase(0, start);
// Collapse the space runs left behind by the removals.
std::string collapsed;
collapsed.reserve(out.size());
bool space = false;
for (const char c : out) {
if (c == ' ') {
space = true;
continue;
}
if (space && !collapsed.empty()) collapsed.push_back(' ');
space = false;
collapsed.push_back(c);
}
// "Austen, Jane" is the same person as "Jane Austen", and publishers use both.
// The spelling vote cannot settle it — with one book per author there is no
// majority — so the inverted form is turned round here instead. Only a single
// comma qualifies: "Smith, John, Jr." and lists of several authors are left
// exactly as they are rather than being scrambled.
const size_t comma = collapsed.find(',');
if (comma != std::string::npos && collapsed.find(',', comma + 1) == std::string::npos) {
std::string_view last(collapsed.data(), comma);
std::string_view first(collapsed.data() + comma + 1, collapsed.size() - comma - 1);
while (!first.empty() && first.front() == ' ') first.remove_prefix(1);
while (!last.empty() && last.back() == ' ') last.remove_suffix(1);
if (!first.empty() && !last.empty()) {
std::string swapped;
swapped.reserve(collapsed.size());
swapped.append(first);
swapped.push_back(' ');
swapped.append(last);
return swapped;
}
}
return collapsed;
}
std::string authorKey(const std::string_view author) {
// Drop bracketed spans ("George Sand [Sand, George]") and everything after
// a multi-author separator.
std::string cleaned;
cleaned.reserve(author.size());
int depth = 0;
for (const char c : author) {
if (c == '[' || c == '(') {
depth++;
continue;
}
if (c == ']' || c == ')') {
if (depth > 0) depth--;
continue;
}
if (c == ';') break;
if (depth == 0) cleaned.push_back(c);
}
const std::string folded = fold(cleaned);
constexpr size_t MAX_TOKENS = 12;
std::string_view tokens[MAX_TOKENS];
size_t count = 0;
splitTokens(folded, tokens, MAX_TOKENS, count);
// Initials carry no identity and appear inconsistently ("Herbert G Wells" vs
// "Herbert Wells"), so they must not change the key.
size_t kept = 0;
for (size_t i = 0; i < count; i++) {
if (tokens[i].size() > 1) tokens[kept++] = tokens[i];
}
std::sort(tokens, tokens + kept);
std::string key;
for (size_t i = 0; i < kept; i++) {
if (!key.empty()) key.push_back(' ');
key.append(tokens[i]);
}
// Truncate on bytes, not on a token boundary. Sorting puts a short forename
// first, so a whole-token cut would reduce "Wollstonecraft, Mary" to the key
// "alex" and merge every Alex in the library; the byte cut keeps
// "mary wollsto", which stays a prefix of the full key and discriminates.
if (key.size() > AUTHOR_KEY_MAX_BYTES) key.resize(AUTHOR_KEY_MAX_BYTES);
while (!key.empty() && key.back() == ' ') key.pop_back();
return key;
}
// fold() keeps the apostrophe, which is right for sorting — "L'Eneide" belongs
// under L. For searching it is wrong: in French the word worth typing is the one
// AFTER the apostrophe, so "eneide" must reach "L'Eneide" and "cote" must
// reach "d'a cote". Treating it as a word boundary here leaves the sort untouched.
bool isWordBreak(const char c) { return c == ' ' || c == '\''; }
bool matchesQuery(const std::string_view haystack, const std::string_view needle) {
if (needle.empty()) return true;
// Walk the query one word at a time, and for each one scan the book's words for
// a prefix hit. Both strings are at most a couple of hundred bytes and this
// runs once per book per keypress, so a plain scan is cheaper than anything
// that would need building first.
size_t qs = 0;
while (qs < needle.size()) {
while (qs < needle.size() && isWordBreak(needle[qs])) qs++;
if (qs >= needle.size()) break;
size_t qe = qs;
while (qe < needle.size() && !isWordBreak(needle[qe])) qe++;
const std::string_view word = needle.substr(qs, qe - qs);
bool found = false;
size_t hs = 0;
while (hs < haystack.size() && !found) {
while (hs < haystack.size() && isWordBreak(haystack[hs])) hs++;
if (hs >= haystack.size()) break;
if (haystack.compare(hs, word.size(), word) == 0) {
found = true;
break;
}
while (hs < haystack.size() && !isWordBreak(haystack[hs])) hs++;
}
if (!found) return false;
qs = qe;
}
return true;
}
std::string surnameKey(const std::string_view displayAuthor) {
const std::string folded = fold(displayAuthor);
if (folded.empty()) return {};
size_t lastStart = std::string::npos;
size_t lastEnd = folded.size();
size_t i = folded.size();
while (i > 0) {
i--;
if (folded[i] != ' ') {
if (lastStart == std::string::npos) lastEnd = i + 1;
lastStart = i;
} else if (lastStart != std::string::npos) {
break;
}
}
if (lastStart == std::string::npos) return {};
std::string key;
key.reserve(folded.size() + 1);
key.append(folded, lastStart, lastEnd - lastStart);
// The given names follow, so two people sharing a surname stay in a stable,
// readable order rather than whichever the disk walk happened to produce.
if (lastStart > 0) {
key.push_back(' ');
key.append(folded, 0, lastStart);
while (!key.empty() && key.back() == ' ') key.pop_back();
}
return key;
}
} // namespace library
+111
View File
@@ -0,0 +1,111 @@
#pragma once
// Text normalisation for the Library index: the fold used for search and sort
// keys, and the author key that merges spellings of one person.
//
// Nothing here reads a filename. Titles and authors come from the book's own
// metadata; a book whose metadata carries no title falls back to its filename
// stem, unparsed, and one with no author is grouped under Unknown.
//
// Pure functions over UTF-8, no hardware and no allocation beyond the returned
// strings, so the whole unit is host-testable (test/library_text).
//
// Design notes that are easy to get wrong and were measured on a real card
// (docs/superpowers/specs/2026-08-05-addendum-a-findability.md, A2.1-A2.8):
//
// * The fold DECOMPOSES. `utf8ComposeNfc()` goes the other way, so a fold
// built on it passes on a card holding only decomposed text and then mangles
// the first precomposed file to arrive from Windows or Calibre. Both forms
// must produce the same output, and the host tests assert exactly that.
// * Some letters have no canonical decomposition at all — U+00F8 (ø) is a
// distinct letter, not o-with-stroke — so a decompose-only fold turns
// "Søren" into "Sren". Those need an explicit map.
// * The author KEY sorts its tokens, so for identity purposes name order
// stops mattering and no First/Last guess is needed there. Display-side
// helpers do use narrow rules — cleanPersonName inverts a single-comma
// "Last, First", surnameKey takes the last word — because showing
// "Austen, Jane" and "Jane Austen" as two people is worse than the guess.
#include <cstdint>
#include <string>
#include <string_view>
namespace library {
// Longest author key written into an index record. Sized so a key fits the
// record's fixed field; measured to collide 0 times over a 69-book library.
inline constexpr size_t AUTHOR_KEY_MAX_BYTES = 12;
// Casefold and strip diacritics for matching and sorting.
//
// Maps a handful of letters that have no canonical decomposition, decomposes the
// rest and drops combining marks, lowercases ASCII alphanumerics, and turns
// everything else into a single space. Space runs collapse and the result is
// trimmed. Apostrophes survive as ASCII '\'' so "O'Malley" and "L'\xC3\x89n\xC3\xA9ideé"
// keep their shape.
//
// `stripArticle` additionally removes one leading article ("the ", "le ", "la ",
// ...) — correct for sort keys and search text, wrong for anything displayed.
std::string fold(std::string_view text, bool stripArticle = false);
// Tidy a person's name for DISPLAY, without reordering it.
//
// Drops bracketed spans ("George Sand [Sand, George]"), everything after a
// multi-author separator, and the trailing underscores and punctuation that
// exporters leave behind ("Lu Xun_", "Herbert G_ Wells"). An underscore
// between letters becomes a full stop, since that is what it replaced in a name
// a filesystem refused to hold.
//
// A single-comma "Austen, Jane" IS turned round into "Jane Austen" — with one
// book per author the spelling vote has no majority to settle it, so the comma
// is the one signal acted on here. Multi-comma names ("Smith, John, Jr.") and
// author lists are left exactly as written. Harmonising the several spellings
// of one person is done by picking the most common one that actually occurs,
// which needs the whole library and so belongs to the index build.
std::string cleanPersonName(std::string_view author);
// Order-insensitive identity for one person, at most AUTHOR_KEY_MAX_BYTES.
//
// Drops bracketed spans and everything after ';' (multi-author separator), folds,
// drops single-character tokens (initials), sorts the remaining tokens and joins
// them. "Lu, Xun", "Xun, Lu" and "Lu Xun [Xun, Lu]" all
// collapse to one key. Truncation is on BYTES, not a token boundary: the sort
// puts a short forename first, so a whole-token cut would reduce
// "Wollstonecraft, Mary" to "alex" and merge every Alex in the library; the byte
// cut keeps "mary wollsto", still a prefix of the full key.
std::string authorKey(std::string_view author);
// Does a book match what has been typed so far?
//
// Both sides are already folded — accents stripped, case dropped, punctuation
// turned to spaces — so "eneide" finds "L'Énéide" and "eluard" finds
// "Éluard". `haystack` is the record's stored fold; `needle` is the query put
// through the same fold.
//
// Every query word must PREFIX some word of the book. That is the rule that fits
// the hardware: with no partial refresh, each keypress costs a full ~185 ms panel
// repaint, so the reader wants to stop typing as early as possible. "dar mat"
// — six keys — finds "Wuthering Heights", where a plain substring test would demand the
// whole of one word and give nothing for the effort of a second.
//
// An empty query matches everything, so the list is the unfiltered shelf before
// the first key is pressed.
bool matchesQuery(std::string_view haystack, std::string_view needle);
// Ordering key for a shelf sorted by author: surname first, then the rest.
// "Herman Melville" becomes "melville herman", so the shelf reads C where a library
// would put it.
//
// Deliberately NOT the same key as authorKey(). That one sorts a name's words so
// that "Victor Hugo" and "Hugo Victor" hash alike and are recognised as one person;
// it is a GROUPING key and would be wrong to order by. This is derived from the
// DISPLAY name instead, which is safe because the spelling vote has already made
// every book by one author show the same name — so a group cannot split across
// two places on the shelf.
//
// The last word is taken as the surname. That is right for the western names on
// this card and wrong for some others, which is a limit worth stating rather than
// hiding: a single word name simply keys on itself.
std::string surnameKey(std::string_view displayAuthor);
} // namespace library
+8
View File
@@ -23,6 +23,14 @@ uint32_t utf8ComposePair(const uint32_t base, const uint32_t mark) {
}
} // namespace
uint32_t utf8DecomposedBase(const uint32_t cp) {
if (cp < 0x00C0) return 0; // no precomposed Latin letter below this
for (const auto& e : kUtf8ComposeTable) {
if (e.composed == cp) return e.base;
}
return 0;
}
std::string utf8ComposeNfc(const std::string& in) {
// Fast path: NFC composition can only change text that contains a combining
// diacritical mark U+0300-036F (UTF-8 lead byte 0xCC or 0xCD). Plain ASCII and
+12
View File
@@ -18,6 +18,18 @@ void utf8TruncateChars(std::string& str, size_t numChars);
// stored in NFD (e.g. some EPUB chapter titles) otherwise renders broken.
std::string utf8ComposeNfc(const std::string& in);
// The base letter a precomposed codepoint decomposes to, or 0 when there is
// none ("é" -> "e", but "ø" -> 0: it is a letter in its own right, not
// o-with-stroke). Lives here rather than in a caller because the compose table
// is a ~5 KB static array in the header: a second includer is a second copy in
// flash.
//
// A linear scan, unlike utf8ComposePair's binary search above it: the table is
// sorted by (base, mark), which this lookup searches against the grain. Adding
// a second table sorted by composed would cost more flash than sharing this one
// saves.
uint32_t utf8DecomposedBase(uint32_t cp);
// Truncate a raw char buffer to the last complete UTF-8 codepoint boundary.
// Returns the new length (<= len). If the buffer ends mid-sequence, the
// incomplete trailing bytes are excluded.
+20 -147
View File
@@ -1,7 +1,6 @@
#include "SleepActivity.h"
#include <Epub.h>
#include <Epub/converters/PngToFramebufferConverter.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalDisplay.h>
@@ -9,7 +8,6 @@
#include <HalStorage.h>
#include <I18n.h>
#include <Memory.h>
#include <PNGdec.h>
#include <Txt.h>
#include <Xtc.h>
@@ -31,8 +29,7 @@
namespace {
// Kept separate from /sleep.bmp and /.sleep so alpha-overlay art does not mix with full-screen wallpapers.
constexpr char TRANSPARENT_SLEEP_ROOT_BMP[] = "/sleep-overlay.bmp";
constexpr char TRANSPARENT_SLEEP_ROOT_PNG[] = "/sleep-overlay.png";
constexpr char TRANSPARENT_SLEEP_ROOT[] = "/sleep-overlay.bmp";
constexpr char TRANSPARENT_SLEEP_DIR[] = "/.sleep-overlay";
constexpr char TRANSPARENT_SLEEP_LEGACY_DIR[] = "/sleep-overlay";
constexpr size_t MAX_SLEEP_FILE_NAME_LEN = 256;
@@ -74,50 +71,6 @@ uint32_t readLE32(HalFile& file) {
(static_cast<uint32_t>(b3) << 24);
}
uint32_t readBE32(HalFile& file) {
const int c0 = file.read();
const int c1 = file.read();
const int c2 = file.read();
const int c3 = file.read();
if (c0 < 0 || c1 < 0 || c2 < 0 || c3 < 0) return 0;
return (static_cast<uint32_t>(c0) << 24) | (static_cast<uint32_t>(c1) << 16) | (static_cast<uint32_t>(c2) << 8) |
static_cast<uint32_t>(c3);
}
bool isValidPngHeader(HalFile& file) {
static constexpr uint8_t PNG_SIGNATURE[8] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
static constexpr uint32_t MAX_SOURCE_PIXELS = 2048u * 1536u;
uint8_t signature[8];
if (!file.seek(0) || file.read(signature, sizeof(signature)) != static_cast<int>(sizeof(signature)) ||
!std::equal(std::begin(signature), std::end(signature), std::begin(PNG_SIGNATURE))) {
return false;
}
const uint32_t ihdrLength = readBE32(file);
char chunkType[4];
if (file.read(reinterpret_cast<uint8_t*>(chunkType), sizeof(chunkType)) != static_cast<int>(sizeof(chunkType)) ||
ihdrLength != 13 || !std::equal(std::begin(chunkType), std::end(chunkType), "IHDR")) {
return false;
}
const uint32_t width = readBE32(file);
const uint32_t height = readBE32(file);
const int bitDepth = file.read();
const int colorType = file.read();
const int compression = file.read();
const int filter = file.read();
const int interlace = file.read();
const bool supportedBitDepth =
bitDepth == 8 || ((colorType == PNG_PIXEL_GRAYSCALE || colorType == PNG_PIXEL_INDEXED) &&
(bitDepth == 1 || bitDepth == 2 || bitDepth == 4));
const bool supportedColorType = colorType == PNG_PIXEL_GRAYSCALE || colorType == PNG_PIXEL_TRUECOLOR ||
colorType == PNG_PIXEL_INDEXED || colorType == PNG_PIXEL_GRAY_ALPHA ||
colorType == PNG_PIXEL_TRUECOLOR_ALPHA;
return width > 0 && height > 0 && width <= 2048 && height <= 3072 && width * height <= MAX_SOURCE_PIXELS &&
supportedBitDepth && supportedColorType && compression == 0 && filter == 0 && interlace == 0;
}
BitmapPlacement calculateBitmapPlacement(const int bitmapWidth, const int bitmapHeight, const GfxRenderer& renderer) {
BitmapPlacement placement;
const auto pageWidth = renderer.getScreenWidth();
@@ -382,26 +335,20 @@ void pushRecentSleepIndex(const SleepRecentKind recentKind, const uint16_t idx)
}
}
bool findNextValidSleepImage(HalFile& dir, const SleepRecentKind recentKind, char* name) {
bool findNextValidSleepBmp(HalFile& dir, char* name) {
for (auto dirFile = dir.openNextFile(); dirFile; dirFile = dir.openNextFile()) {
if (dirFile.isDirectory()) continue;
dirFile.getName(name, MAX_SLEEP_FILE_NAME_LEN);
if (name[0] == '\0' || name[0] == '.') continue;
const bool isBmp = FsHelpers::hasBmpExtension(name);
const bool isPng = recentKind == SleepRecentKind::Overlay && FsHelpers::hasPngExtension(std::string_view{name});
if (!isBmp && !isPng) {
if (!FsHelpers::hasBmpExtension(name)) {
LOG_DBG("SLP", "Skipping unsupported sleep image: %s", name);
continue;
}
const bool isValid = isBmp ? [&dirFile]() {
Bitmap bitmap(dirFile);
return bitmap.parseHeaders() == BmpReaderError::Ok;
}()
: isValidPngHeader(dirFile);
if (!isValid) {
Bitmap bitmap(dirFile);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
LOG_DBG("SLP", "Skipping invalid sleep image: %s", name);
continue;
}
@@ -421,7 +368,7 @@ bool selectRandomSleepFile(const char* dirPath, const SleepRecentKind recentKind
}
uint16_t fileCount = 0;
while (fileCount < UINT16_MAX && findNextValidSleepImage(dir, recentKind, name.get())) ++fileCount;
while (fileCount < UINT16_MAX && findNextValidSleepBmp(dir, name.get())) ++fileCount;
if (fileCount == 0) return false;
// Pick a random wallpaper, excluding recently shown ones.
@@ -436,7 +383,7 @@ bool selectRandomSleepFile(const char* dirPath, const SleepRecentKind recentKind
dir.rewindDirectory();
for (uint16_t index = 0; index <= randomFileIndex; ++index) {
if (!findNextValidSleepImage(dir, recentKind, name.get())) return false;
if (!findNextValidSleepBmp(dir, name.get())) return false;
}
selectedPath.reserve(strlen(dirPath) + 1 + strlen(name.get()));
@@ -448,34 +395,6 @@ bool selectRandomSleepFile(const char* dirPath, const SleepRecentKind recentKind
return true;
}
bool drawSleepPopupPreservingFrame(GfxRenderer& renderer) {
const auto& metrics = UITheme::getInstance().getMetrics();
const int frameThickness = metrics.popupFrameThickness;
const int popupY = static_cast<int>(renderer.getScreenHeight() * metrics.popupTopOffsetRatio);
const int popupHeight = renderer.getLineHeight(UI_12_FONT_ID) + metrics.popupMarginY * 2;
const int bandTop = std::max(0, popupY - frameThickness);
const int bandBottom = std::min(renderer.getScreenHeight(), popupY + popupHeight + frameThickness);
const int bandHeight = bandBottom - bandTop;
const size_t bandBytes = renderer.getRegionByteSize(0, bandTop, renderer.getScreenWidth(), bandHeight);
auto savedBand = makeUniqueNoThrow<uint8_t[]>(bandBytes);
if (!savedBand) {
LOG_ERR("SLP", "OOM: sleep popup background (%u bytes)", static_cast<unsigned>(bandBytes));
return false;
}
if (!renderer.copyRegionToBuffer(0, bandTop, renderer.getScreenWidth(), bandHeight, savedBand.get(), bandBytes)) {
LOG_ERR("SLP", "Failed to save sleep popup background");
return false;
}
GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP));
if (!renderer.copyBufferToRegion(0, bandTop, renderer.getScreenWidth(), bandHeight, savedBand.get(), bandBytes)) {
LOG_ERR("SLP", "Failed to restore sleep popup background");
return false;
}
return true;
}
} // namespace
void SleepActivity::onEnter() {
@@ -502,10 +421,6 @@ void SleepActivity::onEnter() {
// output-level inversion first so the retained content keeps its visible
// polarity after the display driver returns to normal.
if (frameWasInverted) renderer.invertScreen();
if (APP_STATE.lastSleepFromReader) {
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
}
drawSleepPopupPreservingFrame(renderer);
if (APP_STATE.lastSleepFromReader) {
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
}
@@ -670,68 +585,26 @@ bool SleepActivity::renderSleepOverlayFile(HalFile& file, const char* pathForLog
return true;
}
bool SleepActivity::renderTransparentOverlayPng(const std::string& path) const {
ImageDimensions dimensions;
if (!PngToFramebufferConverter::getDimensionsStatic(path, dimensions)) return false;
const auto placement = calculateBitmapPlacement(dimensions.width, dimensions.height, renderer);
RenderConfig config;
config.x = placement.x;
config.y = placement.y;
config.maxWidth = renderer.getScreenWidth();
config.maxHeight = renderer.getScreenHeight();
config.useDithering = false;
config.sourceCropX = placement.cropX;
config.sourceCropY = placement.cropY;
config.useExactDimensions = placement.cropX > 0.0f || placement.cropY > 0.0f;
config.preserveAlpha = true;
PngToFramebufferConverter converter;
LOG_DBG("SLP", "Rendering transparent PNG overlay: %s (%dx%d)", path.c_str(), dimensions.width, dimensions.height);
if (!converter.decodeToFramebuffer(path, renderer, config)) return false;
renderer.displayGrayscaleBase(HalDisplay::HALF_REFRESH);
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (!converter.decodeToFramebuffer(path, renderer, config)) {
renderer.setRenderMode(GfxRenderer::BW);
return true;
}
renderer.copyGrayscaleLsbBuffers();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (!converter.decodeToFramebuffer(path, renderer, config)) {
renderer.setRenderMode(GfxRenderer::BW);
return true;
}
renderer.copyGrayscaleMsbBuffers();
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
return true;
}
bool SleepActivity::renderSleepOverlayPath(const std::string& path) const {
if (FsHelpers::hasPngExtension(path)) {
return Storage.exists(path.c_str()) && renderTransparentOverlayPng(path);
}
HalFile file;
return Storage.openFileForRead("SLP", path, file) && renderSleepOverlayFile(file, path.c_str());
}
void SleepActivity::renderTransparentCustomSleepScreen() const {
if (renderSleepOverlayPath(TRANSPARENT_SLEEP_ROOT_BMP)) return;
if (renderSleepOverlayPath(TRANSPARENT_SLEEP_ROOT_PNG)) return;
{
HalFile rootFile;
if (Storage.openFileForRead("SLP", TRANSPARENT_SLEEP_ROOT, rootFile)) {
if (renderSleepOverlayFile(rootFile, TRANSPARENT_SLEEP_ROOT)) return;
}
}
std::string selectedPath;
if (!selectRandomSleepFile(TRANSPARENT_SLEEP_DIR, SleepRecentKind::Overlay, selectedPath)) {
selectRandomSleepFile(TRANSPARENT_SLEEP_LEGACY_DIR, SleepRecentKind::Overlay, selectedPath);
}
if (!selectedPath.empty() && renderSleepOverlayPath(selectedPath)) return;
if (!selectedPath.empty()) {
HalFile overlayFile;
if (Storage.openFileForRead("SLP", selectedPath, overlayFile) &&
renderSleepOverlayFile(overlayFile, selectedPath.c_str())) {
return;
}
}
LOG_ERR("SLP", "No valid transparent sleep overlay found");
renderDefaultSleepScreen();
@@ -1,6 +1,4 @@
#pragma once
#include <string>
#include "activities/Activity.h"
class Bitmap;
@@ -18,8 +16,6 @@ class SleepActivity final : public Activity {
void renderCoverSleepScreen() const;
void renderBitmapSleepScreen(const Bitmap& bitmap, bool preserveBackground = false) const;
bool renderSleepOverlayFile(HalFile& file, const char* pathForLog) const;
bool renderTransparentOverlayPng(const std::string& path) const;
bool renderSleepOverlayPath(const std::string& path) const;
void renderLastScreenSleepScreen() const;
void renderTransparentCustomSleepScreen() const;
void renderBlankSleepScreen() const;
+1 -1
View File
@@ -69,7 +69,7 @@ void FileBrowserActivity::loadFiles() {
}
} else if (FsHelpers::hasEpubExtension(filename) || FsHelpers::hasXtcExtension(filename) ||
FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename) ||
FsHelpers::hasBmpExtension(filename) || FsHelpers::hasPngExtension(filename)) {
FsHelpers::hasBmpExtension(filename)) {
files.emplace_back(filename);
}
}
+2 -4
View File
@@ -26,9 +26,7 @@ bool ReaderActivity::isTxtFile(const std::string& path) {
FsHelpers::hasMarkdownExtension(path); // Treat .md as txt files (until we have a markdown reader)
}
bool ReaderActivity::isImageFile(const std::string& path) {
return FsHelpers::hasBmpExtension(path) || FsHelpers::hasPngExtension(path);
}
bool ReaderActivity::isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); }
int ReaderActivity::initialRefreshCountdown() const {
if (!allowFastInitialRefresh) return 0;
@@ -154,7 +152,7 @@ void ReaderActivity::onEnter() {
sdFontSystem.ensureLoaded(renderer);
currentBookPath = initialBookPath;
if (isImageFile(initialBookPath)) {
if (isBmpFile(initialBookPath)) {
onGoToBmpViewer(initialBookPath);
} else if (isXtcFile(initialBookPath)) {
auto xtc = loadXtc(initialBookPath);
+1 -1
View File
@@ -18,7 +18,7 @@ class ReaderActivity final : public Activity {
static std::unique_ptr<Txt> loadTxt(const std::string& path);
static bool isXtcFile(const std::string& path);
static bool isTxtFile(const std::string& path);
static bool isImageFile(const std::string& path);
static bool isBmpFile(const std::string& path);
void goToLibrary(const std::string& fromBookPath = "");
void onGoToEpubReader(std::unique_ptr<Epub> epub);
+8 -53
View File
@@ -1,7 +1,6 @@
#include "BmpViewerActivity.h"
#include <Bitmap.h>
#include <Epub/converters/PngToFramebufferConverter.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
@@ -17,7 +16,6 @@
namespace {
constexpr char CUSTOM_SLEEP_ROOT_BMP[] = "/sleep.bmp";
constexpr char TRANSPARENT_SLEEP_ROOT_BMP[] = "/sleep-overlay.bmp";
constexpr char TRANSPARENT_SLEEP_ROOT_PNG[] = "/sleep-overlay.png";
constexpr size_t COPY_BUFFER_SIZE = 2048;
} // namespace
@@ -46,7 +44,7 @@ void BmpViewerActivity::loadSiblingImages() {
file.getName(name, sizeof(name));
if (name[0] != '.') {
std::string fname(name);
if (FsHelpers::hasBmpExtension(fname) || FsHelpers::hasPngExtension(fname)) {
if (FsHelpers::hasBmpExtension(fname)) {
siblingImages.push_back(fname);
}
}
@@ -65,28 +63,6 @@ void BmpViewerActivity::loadSiblingImages() {
}
}
bool BmpViewerActivity::canSetSleepCover() const {
return FsHelpers::hasBmpExtension(filePath) ||
(SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::TRANSPARENT_CUSTOM &&
FsHelpers::hasPngExtension(filePath));
}
bool BmpViewerActivity::renderPng() {
ImageDimensions dimensions;
if (!PngToFramebufferConverter::getDimensionsStatic(filePath, dimensions)) return false;
if (dimensions.width <= 0 || dimensions.height <= 0) return false;
const float scale = std::min(static_cast<float>(renderer.getScreenWidth()) / dimensions.width,
static_cast<float>(renderer.getScreenHeight()) / dimensions.height);
const int width = std::min(renderer.getScreenWidth(), static_cast<int>(dimensions.width * std::min(scale, 1.0f)));
const int height = std::min(renderer.getScreenHeight(), static_cast<int>(dimensions.height * std::min(scale, 1.0f)));
RenderConfig config{(renderer.getScreenWidth() - width) / 2, (renderer.getScreenHeight() - height) / 2, width,
height};
PngToFramebufferConverter converter;
return converter.decodeToFramebuffer(filePath, renderer, config);
}
void BmpViewerActivity::onEnter() {
Activity::onEnter();
@@ -94,30 +70,13 @@ void BmpViewerActivity::onEnter() {
loadSiblingImages();
}
HalFile file;
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
Rect popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
GUI.fillPopupProgress(renderer, popupRect, 20); // Initial 20% progress
if (FsHelpers::hasPngExtension(filePath)) {
renderer.clearScreen();
const bool hasPrevious = siblingImages.size() > 1 && currentImageIndex > 0;
const bool hasNext = siblingImages.size() > 1 && currentImageIndex != -1 &&
currentImageIndex < static_cast<int>(siblingImages.size()) - 1;
const auto labels = mappedInput.mapLabels(tr(STR_BACK), canSetSleepCover() ? tr(STR_SET_SLEEP_COVER) : "",
hasPrevious ? "<" : "", hasNext ? ">" : "");
if (renderPng()) {
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} else {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_FILE_OPEN_FAILED));
GUI.drawButtonHints(renderer, labels.btn1, "", "", "");
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
return;
}
HalFile file;
// 1. Open the BMP file
// 1. Open the file
if (Storage.openFileForRead("BMP", filePath, file)) {
Bitmap bitmap(file, true);
@@ -149,8 +108,8 @@ void BmpViewerActivity::onEnter() {
bool hasNext = (siblingImages.size() > 1 && currentImageIndex != -1 &&
currentImageIndex < static_cast<int>(siblingImages.size()) - 1);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), canSetSleepCover() ? tr(STR_SET_SLEEP_COVER) : "",
(hasPrevious ? "<" : ""), (hasNext ? ">" : ""));
const auto labels =
mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), (hasPrevious ? "<" : ""), (hasNext ? ">" : ""));
GUI.fillPopupProgress(renderer, popupRect, 50);
@@ -195,11 +154,7 @@ void BmpViewerActivity::doSetSleepCover() {
GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
const bool transparentMode = SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::TRANSPARENT_CUSTOM;
if (!canSetSleepCover()) return;
const char* destination =
transparentMode ? (FsHelpers::hasPngExtension(filePath) ? TRANSPARENT_SLEEP_ROOT_PNG : TRANSPARENT_SLEEP_ROOT_BMP)
: CUSTOM_SLEEP_ROOT_BMP;
const char* destination = transparentMode ? TRANSPARENT_SLEEP_ROOT_BMP : CUSTOM_SLEEP_ROOT_BMP;
bool success = filePath == destination;
if (!success) {
@@ -271,7 +226,7 @@ void BmpViewerActivity::loop() {
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (canSetSleepCover()) doSetSleepCover();
doSetSleepCover();
return;
}
+1 -3
View File
@@ -17,10 +17,8 @@ class BmpViewerActivity final : public Activity {
private:
void loadSiblingImages();
void doSetSleepCover();
bool canSetSleepCover() const;
bool renderPng();
std::string filePath;
std::vector<std::string> siblingImages;
int currentImageIndex = -1;
};
};
+1 -1
View File
@@ -139,7 +139,7 @@ UIIcon UITheme::getFileIcon(const std::string& filename) {
if (FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename)) {
return Text;
}
if (FsHelpers::hasBmpExtension(filename) || FsHelpers::hasPngExtension(filename)) {
if (FsHelpers::hasBmpExtension(filename)) {
return Image;
}
return File;
+1
View File
@@ -44,6 +44,7 @@ add_subdirectory(differential_rounding)
add_subdirectory(hyphenation_eval)
add_subdirectory(utf8_compose)
add_subdirectory(opds_filename)
add_subdirectory(library_text)
add_subdirectory(minibidi_arabic)
add_subdirectory(combining_marks)
add_subdirectory(credential_integrity)
+17
View File
@@ -0,0 +1,17 @@
add_executable(LibraryTextTest
LibraryTextTest.cpp
${REPO_ROOT}/lib/LibraryIndex/LibraryText.cpp
${REPO_ROOT}/lib/Utf8/Utf8.cpp
)
target_include_directories(LibraryTextTest PRIVATE
${REPO_ROOT}/lib/LibraryIndex
${REPO_ROOT}/lib/Utf8
)
target_link_libraries(LibraryTextTest PRIVATE
crosspoint_test_common
GTest::gtest_main
)
gtest_discover_tests(LibraryTextTest)
+243
View File
@@ -0,0 +1,243 @@
#include <gtest/gtest.h>
#include <string>
#include "LibraryText.h"
using library::authorKey;
using library::fold;
namespace {
// The same text in both Unicode normal forms. Every fold test runs both, because
// a card holding only one form makes a one-sided test pass by accident — which
// is exactly how a fold built on utf8ComposeNfc() survives until the first file
// arrives from the other kind of machine.
struct NormalisationPair {
const char* nfc; // precomposed: e-acute is one codepoint
const char* nfd; // decomposed: e followed by a combining acute
const char* expected;
};
constexpr NormalisationPair PAIRS[] = {
{"pand\xC3\xA9mie", "pande\xCC\x81mie", "pandemie"},
{"\xC3\x89"
"clipse totale",
"E\xCC\x81"
"clipse totale",
"eclipse totale"},
{"M\xC3\xA9moires", "Me\xCC\x81moires", "memoires"},
{"Ang\xC3\xA9lina", "Ange\xCC\x81lina", "angelina"},
{"R\xC3\xA9"
"camier",
"Re\xCC\x81"
"camier",
"recamier"},
{"Derri\xC3\xA8re les collines", "Derrie\xCC\x80re les collines", "derriere les collines"},
};
} // namespace
TEST(LibraryFold, BothNormalisationsAgree) {
for (const auto& p : PAIRS) {
EXPECT_EQ(fold(p.nfc), p.expected) << "NFC input: " << p.nfc;
EXPECT_EQ(fold(p.nfd), p.expected) << "NFD input: " << p.nfd;
EXPECT_EQ(fold(p.nfc), fold(p.nfd)) << "forms disagree for " << p.expected;
}
}
TEST(LibraryFold, LettersWithoutCanonicalDecomposition) {
// These have no NFD form at all, so a decompose-only fold silently deletes
// them. "Søren" losing its last letter is the case that motivated the map.
EXPECT_EQ(fold("S\xC3\xB8ren"), "soren");
EXPECT_EQ(fold("\xC3\x98rsted"), "orsted");
EXPECT_EQ(fold("\xC3\x86"
"sop"),
"aesop");
EXPECT_EQ(fold("Stra\xC3\x9F"
"e"),
"strasse");
EXPECT_EQ(fold("\xC5\x81odz"), "lodz");
EXPECT_EQ(fold("s\xC5\x93ur"), "soeur");
}
TEST(LibraryFold, ApostrophesSurviveInNamesAndElisions) {
// U+2019 is what exporters actually emit; folding it to a space would split
// "O'Malley" into two tokens and change both its sort place and its search.
// ("Malley", not "Brien": C++ hex escapes are greedy, so \x99B would parse
// as the single escape 0x99B.)
EXPECT_EQ(fold("O\xE2\x80\x99Malley"), "o'malley");
EXPECT_EQ(fold("O'Malley"), "o'malley");
EXPECT_EQ(fold("L\xE2\x80\x99\xC3\x89n\xC3\xA9ide"), "l'eneide");
}
TEST(LibraryFold, TypographicDashesAndQuotesFoldLikeTheirAsciiForms) {
// An em dash used to stay inside the fold word while an ASCII hyphen broke
// it, so the same title written both ways sorted and searched differently.
EXPECT_EQ(library::fold("a\u2014b"), library::fold("a-b"));
EXPECT_EQ(library::fold("a\u2013b"), library::fold("a-b"));
EXPECT_EQ(library::fold("\u201Cquoted\u201D"), library::fold("\"quoted\""));
}
TEST(LibraryFold, PunctuationSeparatesAndSpaceRunsCollapse) {
EXPECT_EQ(fold("Le juge Untel.T2.Le po\xC3\xA8me"), "le juge untel t2 le poeme");
EXPECT_EQ(fold(" spaced out "), "spaced out");
EXPECT_EQ(fold("a---b"), "a b");
EXPECT_EQ(fold("2085 _ Artificial"), "2085 artificial");
EXPECT_EQ(fold(""), "");
EXPECT_EQ(fold("!!!"), "");
}
TEST(LibraryFold, ArticleStrippingOnlyWhenAsked) {
EXPECT_EQ(fold("The Iliad"), "the iliad");
EXPECT_EQ(fold("The Iliad", true), "iliad");
EXPECT_EQ(fold("Les Mis\xC3\xA9rables", true), "miserables");
EXPECT_EQ(fold("L\xE2\x80\x99\xC3\x89n\xC3\xA9ide", true), "eneide");
// A title that IS an article-like word must not vanish.
EXPECT_EQ(fold("The", true), "the");
}
TEST(LibraryAuthorKey, OrderAndPunctuationDoNotMatter) {
const std::string expected = authorKey("Lu Xun");
EXPECT_FALSE(expected.empty());
for (const char* spelling : {"Lu, Xun", "Xun, Lu", "Lu Xun_", "Lu Xun [Xun, Lu]", " lu xun "}) {
EXPECT_EQ(authorKey(spelling), expected) << spelling;
}
}
TEST(LibraryAuthorKey, InitialsAreIgnored) {
EXPECT_EQ(authorKey("Herbert G Wells"), authorKey("Herbert Wells"));
EXPECT_EQ(authorKey("Wells, Herbert G."), authorKey("Herbert Wells"));
}
TEST(LibraryAuthorKey, SecondaryAuthorsAndBracketsDropped) {
EXPECT_EQ(authorKey("Emile Erckmann; Alexandre Chatrian"), authorKey("Emile Erckmann"));
EXPECT_EQ(authorKey("George Sand [Sand, George]"), authorKey("George Sand"));
}
TEST(LibraryAuthorKey, DistinctPeopleDoNotCollide) {
EXPECT_NE(authorKey("Mary Wollstonecraft"), authorKey("Charlotte Bronte"));
EXPECT_NE(authorKey("Victor Hugo"), authorKey("Jules Verne"));
}
TEST(LibraryAuthorKey, FitsTheRecordFieldWithoutCollapsingToAForename) {
const std::string key = authorKey("Bartholomew Fitzgerald Wellington");
ASSERT_FALSE(key.empty());
EXPECT_LE(key.size(), library::AUTHOR_KEY_MAX_BYTES);
EXPECT_NE(key.back(), ' ');
// Sorting puts a short forename first, so cutting on a token boundary would
// reduce this to "mary" and merge every Alex in the library. The byte cut must
// keep enough of the surname to discriminate.
const std::string mary = authorKey("Wollstonecraft, Mary");
EXPECT_GT(mary.size(), 5u);
EXPECT_NE(mary, "mary");
EXPECT_NE(mary, authorKey("Mary Trevelyan"));
// A truncated key stays a prefix of the untruncated one, so grouping is stable
// however long the name is.
EXPECT_EQ(authorKey("Wollstonecraft, Maryse").rfind("mary", 0), 0u);
EXPECT_FALSE(authorKey("Nebuchadnezzarson").empty());
EXPECT_TRUE(authorKey("").empty());
EXPECT_TRUE(authorKey("Q. X. Z.").empty()); // initials only: no identity
}
// --- matchesQuery ------------------------------------------------------------
//
// Cases taken from the shape of the accented and
// apostrophised titles real cards hold — what a naive matcher gets wrong.
TEST(MatchesQuery, EmptyQueryMatchesEverything) {
EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), ""));
}
TEST(MatchesQuery, WholeWordMatches) {
EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("heights")));
}
TEST(MatchesQuery, PrefixOfOneWordIsEnough) {
EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("hei")));
}
// The point of the whole design: six keypresses instead of ten, on a panel where
// each one costs a full repaint.
TEST(MatchesQuery, EveryWordMayBeAbbreviated) {
EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("wut hei")));
}
TEST(MatchesQuery, WordsNeedNotBeInOrder) {
EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("heights wuthering")));
}
TEST(MatchesQuery, EveryWordMustHit) {
EXPECT_FALSE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("wuthering blue")));
}
// A prefix, not a substring: "eights" is inside "heights" but starts no word.
TEST(MatchesQuery, MidWordDoesNotMatch) {
EXPECT_FALSE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("eights")));
}
TEST(MatchesQuery, AccentsAreIgnoredOnBothSides) {
EXPECT_TRUE(library::matchesQuery(library::fold("L'Énéide"), library::fold("eneide")));
EXPECT_TRUE(library::matchesQuery(library::fold("L'Eneide"), library::fold("énéide")));
EXPECT_TRUE(library::matchesQuery(library::fold("Éluard"), library::fold("eluard")));
}
TEST(MatchesQuery, ApostropheSplitsWords) {
EXPECT_TRUE(library::matchesQuery(library::fold("Le bureau d'à côté"), library::fold("cote")));
}
TEST(MatchesQuery, CaseIsIgnored) {
EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("HEIGHTS")));
}
// The stored fold is capped at 96 bytes, so a query word beyond that cannot be
// found. Asserted rather than left implicit: it is the one place a search can
// honestly fail to find a book that is really there.
TEST(MatchesQuery, LongTitlesAreOnlySearchableWithinTheStoredFold) {
const std::string longTitle(120, 'a');
const std::string folded = library::fold(longTitle + " needle").substr(0, 96);
EXPECT_FALSE(library::matchesQuery(folded, library::fold("needle")));
}
// --- inverted author names ---------------------------------------------------
TEST(CleanPersonName, InvertedNameIsTurnedRound) {
EXPECT_EQ(library::cleanPersonName("Austen, Jane"), "Jane Austen");
EXPECT_EQ(library::cleanPersonName("Wollstonecraft, Mary"), "Mary Wollstonecraft");
}
TEST(CleanPersonName, PlainNameIsUntouched) { EXPECT_EQ(library::cleanPersonName("Emily Bronte"), "Emily Bronte"); }
// Two commas mean a suffix or a list, not an inversion — leave it alone rather
// than scramble it.
TEST(CleanPersonName, MultipleCommasAreLeftAlone) {
// The trailing full stop is stripped by the existing noise rules.
EXPECT_EQ(library::cleanPersonName("Smith, John, Jr."), "Smith, John, Jr");
}
TEST(CleanPersonName, DanglingCommaIsNotAnInversion) { EXPECT_EQ(library::cleanPersonName("Austen,"), "Austen"); }
// --- surnameKey --------------------------------------------------------------
TEST(SurnameKey, SurnameLeadsThenGivenNames) {
EXPECT_EQ(library::surnameKey("Herman Melville"), "melville herman");
EXPECT_EQ(library::surnameKey("Mary Wollstonecraft"), "wollstonecraft mary");
}
TEST(SurnameKey, SingleWordKeysOnItself) { EXPECT_EQ(library::surnameKey("Voltaire"), "voltaire"); }
TEST(SurnameKey, AccentsAreFolded) { EXPECT_EQ(library::surnameKey("Paul Éluard"), "eluard paul"); }
TEST(SurnameKey, ThreeWordNamesTakeTheLast) { EXPECT_EQ(library::surnameKey("Herbert G. Wells"), "wells herbert g"); }
TEST(SurnameKey, EmptyStaysEmpty) { EXPECT_EQ(library::surnameKey(""), ""); }
// The whole point of keying off the DISPLAY name: the spelling vote has already
// made every book by one author show one name, so a group cannot land in two
// places even though "Victor Hugo" and "Hugo Victor" both exist in the wild.
TEST(SurnameKey, HarmonisedDisplayNameKeepsAGroupTogether) {
EXPECT_NE(library::surnameKey("Victor Hugo"), library::surnameKey("Hugo Victor"));
}