mirror of
https://github.com/crosspoint-reader/crosspoint-reader.git
synced 2026-08-13 14:21:57 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f45118c48e |
No files matched your search
+6
-6
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in new issue
Block a user