Merge remote-tracking branch 'origin/master' into hyphenation-v3

This commit is contained in:
Arthur Tazhitdinov 2026-01-13 18:41:56 +05:00
commit f457ed021d
28 changed files with 526 additions and 489 deletions

View File

@ -1,9 +1,18 @@
## Summary ## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user authentication module, Implements the new feature for * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.)
file uploading.)
* **What changes are included?** * **What changes are included?**
## Additional Context ## Additional Context
* Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks,
specific areas to focus on).
---
### AI Usage
While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it
helps set the right context for reviewers.
Did you use AI tools to help write this code? _**< YES | PARTIALLY | NO >**_

View File

@ -7,11 +7,11 @@ name: CI
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
submodules: recursive submodules: recursive
- uses: actions/setup-python@v6 - uses: actions/setup-python@v6
with: with:
python-version: '3.14' python-version: '3.14'

View File

@ -7,17 +7,18 @@ on:
jobs: jobs:
build-release: build-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
submodules: recursive submodules: recursive
- uses: actions/cache@v5 - uses: actions/cache@v5
with: with:
path: | path: |
~/.cache/pip ~/.cache/pip
~/.platformio/.cache ~/.platformio/.cache
key: ${{ runner.os }}-pio key: ${{ runner.os }}-pio
- uses: actions/setup-python@v6 - uses: actions/setup-python@v6
with: with:
python-version: '3.14' python-version: '3.14'

View File

@ -67,6 +67,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
- "Light" - The same default sleep screen, on a white background - "Light" - The same default sleep screen, on a white background
- "Custom" - Custom images from the SD card, see [Sleep Screen](#36-sleep-screen) below for more information - "Custom" - Custom images from the SD card, see [Sleep Screen](#36-sleep-screen) below for more information
- "Cover" - The book cover image (Note: this is experimental and may not work as expected) - "Cover" - The book cover image (Note: this is experimental and may not work as expected)
- "Blank" - A blank screen
- **Status Bar**: Configure the status bar displayed while reading: - **Status Bar**: Configure the status bar displayed while reading:
- "None" - No status bar - "None" - No status bar
- "No Progress" - Show status bar without reading progress - "No Progress" - Show status bar without reading progress

View File

@ -168,7 +168,10 @@ bool Epub::parseTocNavFile() const {
} }
const auto navSize = tempNavFile.size(); const auto navSize = tempNavFile.size();
TocNavParser navParser(contentBasePath, navSize, bookMetadataCache.get()); // Note: We can't use `contentBasePath` here as the nav file may be in a different folder to the content.opf
// and the HTMLX nav file will have hrefs relative to itself
const std::string navContentBasePath = tocNavItem.substr(0, tocNavItem.find_last_of('/') + 1);
TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get());
if (!navParser.setup()) { if (!navParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis()); Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis());
@ -355,11 +358,23 @@ const std::string& Epub::getLanguage() const {
return bookMetadataCache->coreMetadata.language; return bookMetadataCache->coreMetadata.language;
} }
std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } const std::string& Epub::getLanguage() const {
static std::string blank;
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return blank;
}
bool Epub::generateCoverBmp() const { return bookMetadataCache->coreMetadata.language;
}
std::string Epub::getCoverBmpPath(bool cropped) const {
const auto coverFileName = "cover" + cropped ? "_crop" : "";
return cachePath + "/" + coverFileName + ".bmp";
}
bool Epub::generateCoverBmp(bool cropped) const {
// Already generated, return true // Already generated, return true
if (SdMan.exists(getCoverBmpPath().c_str())) { if (SdMan.exists(getCoverBmpPath(cropped).c_str())) {
return true; return true;
} }
@ -391,7 +406,7 @@ bool Epub::generateCoverBmp() const {
} }
FsFile coverBmp; FsFile coverBmp;
if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) { if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
coverJpg.close(); coverJpg.close();
return false; return false;
} }
@ -402,7 +417,7 @@ bool Epub::generateCoverBmp() const {
if (!success) { if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis());
SdMan.remove(getCoverBmpPath().c_str()); SdMan.remove(getCoverBmpPath(cropped).c_str());
} }
Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no"); Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no");
return success; return success;

View File

@ -45,8 +45,8 @@ class Epub {
const std::string& getTitle() const; const std::string& getTitle() const;
const std::string& getAuthor() const; const std::string& getAuthor() const;
const std::string& getLanguage() const; const std::string& getLanguage() const;
std::string getCoverBmpPath() const; std::string getCoverBmpPath(bool cropped = false) const;
bool generateCoverBmp() const; bool generateCoverBmp(bool cropped = false) const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const; bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;

View File

@ -172,7 +172,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (strcmp(atts[i], "id") == 0) { if (strcmp(atts[i], "id") == 0) {
itemId = atts[i + 1]; itemId = atts[i + 1];
} else if (strcmp(atts[i], "href") == 0) { } else if (strcmp(atts[i], "href") == 0) {
href = self->baseContentPath + atts[i + 1]; href = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]);
} else if (strcmp(atts[i], "media-type") == 0) { } else if (strcmp(atts[i], "media-type") == 0) {
mediaType = atts[i + 1]; mediaType = atts[i + 1];
} else if (strcmp(atts[i], "properties") == 0) { } else if (strcmp(atts[i], "properties") == 0) {
@ -248,7 +248,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
break; break;
} }
} else if (strcmp(atts[i], "href") == 0) { } else if (strcmp(atts[i], "href") == 0) {
textHref = self->baseContentPath + atts[i + 1]; textHref = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]);
} }
} }
if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) { if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) {

View File

@ -1,5 +1,6 @@
#include "TocNavParser.h" #include "TocNavParser.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include "../BookMetadataCache.h" #include "../BookMetadataCache.h"
@ -140,7 +141,7 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) { if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) {
// Create TOC entry when closing anchor tag (we have all data now) // Create TOC entry when closing anchor tag (we have all data now)
if (!self->currentLabel.empty() && !self->currentHref.empty()) { if (!self->currentLabel.empty() && !self->currentHref.empty()) {
std::string href = self->baseContentPath + self->currentHref; std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentHref);
std::string anchor; std::string anchor;
const size_t pos = href.find('#'); const size_t pos = href.find('#');

View File

@ -1,5 +1,6 @@
#include "TocNcxParser.h" #include "TocNcxParser.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include "../BookMetadataCache.h" #include "../BookMetadataCache.h"
@ -159,7 +160,7 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) {
// This is the safest place to push the data, assuming <navLabel> always comes before <content>. // This is the safest place to push the data, assuming <navLabel> always comes before <content>.
// NCX spec says navLabel comes before content. // NCX spec says navLabel comes before content.
if (!self->currentLabel.empty() && !self->currentSrc.empty()) { if (!self->currentLabel.empty() && !self->currentSrc.empty()) {
std::string href = self->baseContentPath + self->currentSrc; std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentSrc);
std::string anchor; std::string anchor;
const size_t pos = href.find('#'); const size_t pos = href.find('#');

View File

@ -8,119 +8,15 @@
// ============================================================================ // ============================================================================
// Note: For cover images, dithering is done in JpegToBmpConverter.cpp // Note: For cover images, dithering is done in JpegToBmpConverter.cpp
// This file handles BMP reading - use simple quantization to avoid double-dithering // This file handles BMP reading - use simple quantization to avoid double-dithering
constexpr bool USE_FLOYD_STEINBERG = false; // Disabled - dithering done at JPEG conversion constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering
// Brightness adjustments:
constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments
constexpr int BRIGHTNESS_BOOST = 20; // Brightness offset (0-50), only if USE_BRIGHTNESS=true
constexpr bool GAMMA_CORRECTION = false; // Gamma curve, only if USE_BRIGHTNESS=true
// ============================================================================ // ============================================================================
// Integer approximation of gamma correction (brightens midtones)
static inline int applyGamma(int gray) {
if (!GAMMA_CORRECTION) return gray;
const int product = gray * 255;
int x = gray;
if (x > 0) {
x = (x + product / x) >> 1;
x = (x + product / x) >> 1;
}
return x > 255 ? 255 : x;
}
// Simple quantization without dithering - just divide into 4 levels
static inline uint8_t quantizeSimple(int gray) {
if (USE_BRIGHTNESS) {
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
gray = applyGamma(gray);
}
return static_cast<uint8_t>(gray >> 6);
}
// Hash-based noise dithering - survives downsampling without moiré artifacts
static inline uint8_t quantizeNoise(int gray, int x, int y) {
if (USE_BRIGHTNESS) {
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
gray = applyGamma(gray);
}
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24);
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Main quantization function
static inline uint8_t quantize(int gray, int x, int y) {
if (USE_NOISE_DITHERING) {
return quantizeNoise(gray, x, y);
} else {
return quantizeSimple(gray);
}
}
// Floyd-Steinberg quantization with error diffusion and serpentine scanning
// Returns 2-bit value (0-3) and updates error buffers
static inline uint8_t quantizeFloydSteinberg(int gray, int x, int width, int16_t* errorCurRow, int16_t* errorNextRow,
bool reverseDir) {
// Add accumulated error to this pixel
int adjusted = gray + errorCurRow[x + 1];
// Clamp to valid range
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels (0, 85, 170, 255)
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error
int error = adjusted - quantizedValue;
// Distribute error to neighbors (serpentine: direction-aware)
if (!reverseDir) {
// Left to right
errorCurRow[x + 2] += (error * 7) >> 4; // Right: 7/16
errorNextRow[x] += (error * 3) >> 4; // Bottom-left: 3/16
errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16
errorNextRow[x + 2] += (error) >> 4; // Bottom-right: 1/16
} else {
// Right to left (mirrored)
errorCurRow[x] += (error * 7) >> 4; // Left: 7/16
errorNextRow[x + 2] += (error * 3) >> 4; // Bottom-right: 3/16
errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16
errorNextRow[x] += (error) >> 4; // Bottom-left: 1/16
}
return quantized;
}
Bitmap::~Bitmap() { Bitmap::~Bitmap() {
delete[] errorCurRow; delete[] errorCurRow;
delete[] errorNextRow; delete[] errorNextRow;
delete atkinsonDitherer;
delete fsDitherer;
} }
uint16_t Bitmap::readLE16(FsFile& f) { uint16_t Bitmap::readLE16(FsFile& f) {
@ -244,13 +140,14 @@ BmpReaderError Bitmap::parseHeaders() {
return BmpReaderError::SeekPixelDataFailed; return BmpReaderError::SeekPixelDataFailed;
} }
// Allocate Floyd-Steinberg error buffers if enabled // Create ditherer if enabled (only for 2-bit output)
if (USE_FLOYD_STEINBERG) { // Use OUTPUT dimensions for dithering (after prescaling)
delete[] errorCurRow; if (bpp > 2 && dithering) {
delete[] errorNextRow; if (USE_ATKINSON) {
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling atkinsonDitherer = new AtkinsonDitherer(width);
errorNextRow = new int16_t[width + 2](); } else {
prevRowY = -1; fsDitherer = new FloydSteinbergDitherer(width);
}
} }
return BmpReaderError::Ok; return BmpReaderError::Ok;
@ -261,17 +158,6 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
// Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes'
if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow;
// Handle Floyd-Steinberg error buffer progression
const bool useFS = USE_FLOYD_STEINBERG && errorCurRow && errorNextRow;
if (useFS) {
if (prevRowY != -1) {
// Sequential access - swap buffers
int16_t* temp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = temp;
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
}
}
prevRowY += 1; prevRowY += 1;
uint8_t* outPtr = data; uint8_t* outPtr = data;
@ -282,12 +168,18 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
// Helper lambda to pack 2bpp color into the output stream // Helper lambda to pack 2bpp color into the output stream
auto packPixel = [&](const uint8_t lum) { auto packPixel = [&](const uint8_t lum) {
uint8_t color; uint8_t color;
if (useFS) { if (atkinsonDitherer) {
// Floyd-Steinberg error diffusion color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX);
color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false); } else if (fsDitherer) {
color = fsDitherer->processPixel(adjustPixel(lum), currentX);
} else { } else {
// Simple quantization or noise dithering if (bpp > 2) {
color = quantize(lum, currentX, prevRowY); // Simple quantization or noise dithering
color = quantize(adjustPixel(lum), currentX, prevRowY);
} else {
// do not quantize 2bpp image
color = static_cast<uint8_t>(lum >> 6);
}
} }
currentOutByte |= (color << bitShift); currentOutByte |= (color << bitShift);
if (bitShift == 0) { if (bitShift == 0) {
@ -345,6 +237,11 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
return BmpReaderError::UnsupportedBpp; return BmpReaderError::UnsupportedBpp;
} }
if (atkinsonDitherer)
atkinsonDitherer->nextRow();
else if (fsDitherer)
fsDitherer->nextRow();
// Flush remaining bits if width is not a multiple of 4 // Flush remaining bits if width is not a multiple of 4
if (bitShift != 6) *outPtr = currentOutByte; if (bitShift != 6) *outPtr = currentOutByte;
@ -356,12 +253,9 @@ BmpReaderError Bitmap::rewindToData() const {
return BmpReaderError::SeekPixelDataFailed; return BmpReaderError::SeekPixelDataFailed;
} }
// Reset Floyd-Steinberg error buffers when rewinding // Reset dithering when rewinding
if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) { if (fsDitherer) fsDitherer->reset();
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); if (atkinsonDitherer) atkinsonDitherer->reset();
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
prevRowY = -1;
}
return BmpReaderError::Ok; return BmpReaderError::Ok;
} }

View File

@ -2,6 +2,10 @@
#include <SdFat.h> #include <SdFat.h>
#include <cstdint>
#include "BitmapHelpers.h"
enum class BmpReaderError : uint8_t { enum class BmpReaderError : uint8_t {
Ok = 0, Ok = 0,
FileInvalid, FileInvalid,
@ -28,7 +32,7 @@ class Bitmap {
public: public:
static const char* errorToString(BmpReaderError err); static const char* errorToString(BmpReaderError err);
explicit Bitmap(FsFile& file) : file(file) {} explicit Bitmap(FsFile& file, bool dithering = false) : file(file), dithering(dithering) {}
~Bitmap(); ~Bitmap();
BmpReaderError parseHeaders(); BmpReaderError parseHeaders();
BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const; BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const;
@ -44,6 +48,7 @@ class Bitmap {
static uint32_t readLE32(FsFile& f); static uint32_t readLE32(FsFile& f);
FsFile& file; FsFile& file;
bool dithering = false;
int width = 0; int width = 0;
int height = 0; int height = 0;
bool topDown = false; bool topDown = false;
@ -56,4 +61,7 @@ class Bitmap {
mutable int16_t* errorCurRow = nullptr; mutable int16_t* errorCurRow = nullptr;
mutable int16_t* errorNextRow = nullptr; mutable int16_t* errorNextRow = nullptr;
mutable int prevRowY = -1; // Track row progression for error propagation mutable int prevRowY = -1; // Track row progression for error propagation
mutable AtkinsonDitherer* atkinsonDitherer = nullptr;
mutable FloydSteinbergDitherer* fsDitherer = nullptr;
}; };

View File

@ -0,0 +1,90 @@
#include "BitmapHelpers.h"
#include <cstdint>
// Brightness/Contrast adjustments:
constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments
constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50)
constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones)
constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast)
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering
// Integer approximation of gamma correction (brightens midtones)
// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255)
static inline int applyGamma(int gray) {
if (!GAMMA_CORRECTION) return gray;
// Fast integer square root approximation for gamma ~0.5 (brightening)
// This brightens dark/mid tones while preserving highlights
const int product = gray * 255;
// Newton-Raphson integer sqrt (2 iterations for good accuracy)
int x = gray;
if (x > 0) {
x = (x + product / x) >> 1;
x = (x + product / x) >> 1;
}
return x > 255 ? 255 : x;
}
// Apply contrast adjustment around midpoint (128)
// factor > 1.0 increases contrast, < 1.0 decreases
static inline int applyContrast(int gray) {
// Integer-based contrast: (gray - 128) * factor + 128
// Using fixed-point: factor 1.15 ≈ 115/100
constexpr int factorNum = static_cast<int>(CONTRAST_FACTOR * 100);
int adjusted = ((gray - 128) * factorNum) / 100 + 128;
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
return adjusted;
}
// Combined brightness/contrast/gamma adjustment
int adjustPixel(int gray) {
if (!USE_BRIGHTNESS) return gray;
// Order: contrast first, then brightness, then gamma
gray = applyContrast(gray);
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
if (gray < 0) gray = 0;
gray = applyGamma(gray);
return gray;
}
// Simple quantization without dithering - divide into 4 levels
// The thresholds are fine-tuned to the X4 display
uint8_t quantizeSimple(int gray) {
if (gray < 45) {
return 0;
} else if (gray < 70) {
return 1;
} else if (gray < 140) {
return 2;
} else {
return 3;
}
}
// Hash-based noise dithering - survives downsampling without moiré artifacts
// Uses integer hash to generate pseudo-random threshold per pixel
static inline uint8_t quantizeNoise(int gray, int x, int y) {
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24);
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Main quantization function - selects between methods based on config
uint8_t quantize(int gray, int x, int y) {
if (USE_NOISE_DITHERING) {
return quantizeNoise(gray, x, y);
} else {
return quantizeSimple(gray);
}
}

View File

@ -0,0 +1,233 @@
#pragma once
#include <cstring>
// Helper functions
uint8_t quantize(int gray, int x, int y);
uint8_t quantizeSimple(int gray);
int adjustPixel(int gray);
// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results
// Error distribution pattern:
// X 1/8 1/8
// 1/8 1/8 1/8
// 1/8
// Less error buildup = fewer artifacts than Floyd-Steinberg
class AtkinsonDitherer {
public:
explicit AtkinsonDitherer(int width) : width(width) {
errorRow0 = new int16_t[width + 4](); // Current row
errorRow1 = new int16_t[width + 4](); // Next row
errorRow2 = new int16_t[width + 4](); // Row after next
}
~AtkinsonDitherer() {
delete[] errorRow0;
delete[] errorRow1;
delete[] errorRow2;
}
// **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR**
AtkinsonDitherer(const AtkinsonDitherer& other) = delete;
// **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR**
AtkinsonDitherer& operator=(const AtkinsonDitherer& other) = delete;
uint8_t processPixel(int gray, int x) {
// Add accumulated error
int adjusted = gray + errorRow0[x + 2];
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels
uint8_t quantized;
int quantizedValue;
if (false) { // original thresholds
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
} else { // fine-tuned to X4 eink display
if (adjusted < 30) {
quantized = 0;
quantizedValue = 15;
} else if (adjusted < 50) {
quantized = 1;
quantizedValue = 30;
} else if (adjusted < 140) {
quantized = 2;
quantizedValue = 80;
} else {
quantized = 3;
quantizedValue = 210;
}
}
// Calculate error (only distribute 6/8 = 75%)
int error = (adjusted - quantizedValue) >> 3; // error/8
// Distribute 1/8 to each of 6 neighbors
errorRow0[x + 3] += error; // Right
errorRow0[x + 4] += error; // Right+1
errorRow1[x + 1] += error; // Bottom-left
errorRow1[x + 2] += error; // Bottom
errorRow1[x + 3] += error; // Bottom-right
errorRow2[x + 2] += error; // Two rows down
return quantized;
}
void nextRow() {
int16_t* temp = errorRow0;
errorRow0 = errorRow1;
errorRow1 = errorRow2;
errorRow2 = temp;
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
void reset() {
memset(errorRow0, 0, (width + 4) * sizeof(int16_t));
memset(errorRow1, 0, (width + 4) * sizeof(int16_t));
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
private:
int width;
int16_t* errorRow0;
int16_t* errorRow1;
int16_t* errorRow2;
};
// Floyd-Steinberg error diffusion dithering with serpentine scanning
// Serpentine scanning alternates direction each row to reduce "worm" artifacts
// Error distribution pattern (left-to-right):
// X 7/16
// 3/16 5/16 1/16
// Error distribution pattern (right-to-left, mirrored):
// 1/16 5/16 3/16
// 7/16 X
class FloydSteinbergDitherer {
public:
explicit FloydSteinbergDitherer(int width) : width(width), rowCount(0) {
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling
errorNextRow = new int16_t[width + 2]();
}
~FloydSteinbergDitherer() {
delete[] errorCurRow;
delete[] errorNextRow;
}
// **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR**
FloydSteinbergDitherer(const FloydSteinbergDitherer& other) = delete;
// **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR**
FloydSteinbergDitherer& operator=(const FloydSteinbergDitherer& other) = delete;
// Process a single pixel and return quantized 2-bit value
// x is the logical x position (0 to width-1), direction handled internally
uint8_t processPixel(int gray, int x) {
// Add accumulated error to this pixel
int adjusted = gray + errorCurRow[x + 1];
// Clamp to valid range
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels (0, 85, 170, 255)
uint8_t quantized;
int quantizedValue;
if (false) { // original thresholds
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
} else { // fine-tuned to X4 eink display
if (adjusted < 30) {
quantized = 0;
quantizedValue = 15;
} else if (adjusted < 50) {
quantized = 1;
quantizedValue = 30;
} else if (adjusted < 140) {
quantized = 2;
quantizedValue = 80;
} else {
quantized = 3;
quantizedValue = 210;
}
}
// Calculate error
int error = adjusted - quantizedValue;
// Distribute error to neighbors (serpentine: direction-aware)
if (!isReverseRow()) {
// Left to right: standard distribution
// Right: 7/16
errorCurRow[x + 2] += (error * 7) >> 4;
// Bottom-left: 3/16
errorNextRow[x] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-right: 1/16
errorNextRow[x + 2] += (error) >> 4;
} else {
// Right to left: mirrored distribution
// Left: 7/16
errorCurRow[x] += (error * 7) >> 4;
// Bottom-right: 3/16
errorNextRow[x + 2] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-left: 1/16
errorNextRow[x] += (error) >> 4;
}
return quantized;
}
// Call at the end of each row to swap buffers
void nextRow() {
// Swap buffers
int16_t* temp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = temp;
// Clear the next row buffer
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount++;
}
// Check if current row should be processed in reverse
bool isReverseRow() const { return (rowCount & 1) != 0; }
// Reset for a new image or MCU block
void reset() {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount = 0;
}
private:
int width;
int rowCount;
int16_t* errorCurRow;
int16_t* errorNextRow;
};

View File

@ -7,6 +7,8 @@
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include "BitmapHelpers.h"
// Context structure for picojpeg callback // Context structure for picojpeg callback
struct JpegReadContext { struct JpegReadContext {
FsFile& file; FsFile& file;
@ -23,282 +25,12 @@ constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantizati
constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion) constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion)
constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts) constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts)
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling) constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling)
// Brightness/Contrast adjustments:
constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments
constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50)
constexpr bool GAMMA_CORRECTION = true; // Gamma curve (brightens midtones)
constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast)
// Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling) // Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling)
constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering
constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width) constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width)
constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height) constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height)
// ============================================================================ // ============================================================================
// Integer approximation of gamma correction (brightens midtones)
// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255)
static inline int applyGamma(int gray) {
if (!GAMMA_CORRECTION) return gray;
// Fast integer square root approximation for gamma ~0.5 (brightening)
// This brightens dark/mid tones while preserving highlights
const int product = gray * 255;
// Newton-Raphson integer sqrt (2 iterations for good accuracy)
int x = gray;
if (x > 0) {
x = (x + product / x) >> 1;
x = (x + product / x) >> 1;
}
return x > 255 ? 255 : x;
}
// Apply contrast adjustment around midpoint (128)
// factor > 1.0 increases contrast, < 1.0 decreases
static inline int applyContrast(int gray) {
// Integer-based contrast: (gray - 128) * factor + 128
// Using fixed-point: factor 1.15 ≈ 115/100
constexpr int factorNum = static_cast<int>(CONTRAST_FACTOR * 100);
int adjusted = ((gray - 128) * factorNum) / 100 + 128;
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
return adjusted;
}
// Combined brightness/contrast/gamma adjustment
static inline int adjustPixel(int gray) {
if (!USE_BRIGHTNESS) return gray;
// Order: contrast first, then brightness, then gamma
gray = applyContrast(gray);
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
if (gray < 0) gray = 0;
gray = applyGamma(gray);
return gray;
}
// Simple quantization without dithering - just divide into 4 levels
static inline uint8_t quantizeSimple(int gray) {
gray = adjustPixel(gray);
// Simple 2-bit quantization: 0-63=0, 64-127=1, 128-191=2, 192-255=3
return static_cast<uint8_t>(gray >> 6);
}
// Hash-based noise dithering - survives downsampling without moiré artifacts
// Uses integer hash to generate pseudo-random threshold per pixel
static inline uint8_t quantizeNoise(int gray, int x, int y) {
gray = adjustPixel(gray);
// Generate noise threshold using integer hash (no regular pattern to alias)
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24); // 0-255
// Map gray (0-255) to 4 levels with dithering
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Main quantization function - selects between methods based on config
static inline uint8_t quantize(int gray, int x, int y) {
if (USE_NOISE_DITHERING) {
return quantizeNoise(gray, x, y);
} else {
return quantizeSimple(gray);
}
}
// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results
// Error distribution pattern:
// X 1/8 1/8
// 1/8 1/8 1/8
// 1/8
// Less error buildup = fewer artifacts than Floyd-Steinberg
class AtkinsonDitherer {
public:
AtkinsonDitherer(int width) : width(width) {
errorRow0 = new int16_t[width + 4](); // Current row
errorRow1 = new int16_t[width + 4](); // Next row
errorRow2 = new int16_t[width + 4](); // Row after next
}
~AtkinsonDitherer() {
delete[] errorRow0;
delete[] errorRow1;
delete[] errorRow2;
}
uint8_t processPixel(int gray, int x) {
// Apply brightness/contrast/gamma adjustments
gray = adjustPixel(gray);
// Add accumulated error
int adjusted = gray + errorRow0[x + 2];
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error (only distribute 6/8 = 75%)
int error = (adjusted - quantizedValue) >> 3; // error/8
// Distribute 1/8 to each of 6 neighbors
errorRow0[x + 3] += error; // Right
errorRow0[x + 4] += error; // Right+1
errorRow1[x + 1] += error; // Bottom-left
errorRow1[x + 2] += error; // Bottom
errorRow1[x + 3] += error; // Bottom-right
errorRow2[x + 2] += error; // Two rows down
return quantized;
}
void nextRow() {
int16_t* temp = errorRow0;
errorRow0 = errorRow1;
errorRow1 = errorRow2;
errorRow2 = temp;
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
void reset() {
memset(errorRow0, 0, (width + 4) * sizeof(int16_t));
memset(errorRow1, 0, (width + 4) * sizeof(int16_t));
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
private:
int width;
int16_t* errorRow0;
int16_t* errorRow1;
int16_t* errorRow2;
};
// Floyd-Steinberg error diffusion dithering with serpentine scanning
// Serpentine scanning alternates direction each row to reduce "worm" artifacts
// Error distribution pattern (left-to-right):
// X 7/16
// 3/16 5/16 1/16
// Error distribution pattern (right-to-left, mirrored):
// 1/16 5/16 3/16
// 7/16 X
class FloydSteinbergDitherer {
public:
FloydSteinbergDitherer(int width) : width(width), rowCount(0) {
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling
errorNextRow = new int16_t[width + 2]();
}
~FloydSteinbergDitherer() {
delete[] errorCurRow;
delete[] errorNextRow;
}
// Process a single pixel and return quantized 2-bit value
// x is the logical x position (0 to width-1), direction handled internally
uint8_t processPixel(int gray, int x, bool reverseDirection) {
// Add accumulated error to this pixel
int adjusted = gray + errorCurRow[x + 1];
// Clamp to valid range
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels (0, 85, 170, 255)
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error
int error = adjusted - quantizedValue;
// Distribute error to neighbors (serpentine: direction-aware)
if (!reverseDirection) {
// Left to right: standard distribution
// Right: 7/16
errorCurRow[x + 2] += (error * 7) >> 4;
// Bottom-left: 3/16
errorNextRow[x] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-right: 1/16
errorNextRow[x + 2] += (error) >> 4;
} else {
// Right to left: mirrored distribution
// Left: 7/16
errorCurRow[x] += (error * 7) >> 4;
// Bottom-right: 3/16
errorNextRow[x + 2] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-left: 1/16
errorNextRow[x] += (error) >> 4;
}
return quantized;
}
// Call at the end of each row to swap buffers
void nextRow() {
// Swap buffers
int16_t* temp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = temp;
// Clear the next row buffer
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount++;
}
// Check if current row should be processed in reverse
bool isReverseRow() const { return (rowCount & 1) != 0; }
// Reset for a new image or MCU block
void reset() {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount = 0;
}
private:
int width;
int rowCount;
int16_t* errorCurRow;
int16_t* errorNextRow;
};
inline void write16(Print& out, const uint16_t value) { inline void write16(Print& out, const uint16_t value) {
out.write(value & 0xFF); out.write(value & 0xFF);
out.write((value >> 8) & 0xFF); out.write((value >> 8) & 0xFF);
@ -623,12 +355,12 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
} }
} else { } else {
for (int x = 0; x < outWidth; x++) { for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]);
uint8_t twoBit; uint8_t twoBit;
if (atkinsonDitherer) { if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x); twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) { } else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); twoBit = fsDitherer->processPixel(gray, x);
} else { } else {
twoBit = quantize(gray, x, y); twoBit = quantize(gray, x, y);
} }
@ -686,12 +418,12 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
} }
} else { } else {
for (int x = 0; x < outWidth; x++) { for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0);
uint8_t twoBit; uint8_t twoBit;
if (atkinsonDitherer) { if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x); twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) { } else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); twoBit = fsDitherer->processPixel(gray, x);
} else { } else {
twoBit = quantize(gray, x, currentOutY); twoBit = quantize(gray, x, currentOutY);
} }

View File

@ -2,7 +2,7 @@
default_envs = default default_envs = default
[crosspoint] [crosspoint]
crosspoint_version = 0.12.0 version = 0.13.1
[base] [base]
platform = espressif32 @ 6.12.0 platform = espressif32 @ 6.12.0
@ -52,10 +52,10 @@ lib_deps =
extends = base extends = base
build_flags = build_flags =
${base.build_flags} ${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.crosspoint_version}-dev\" -DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\"
[env:gh_release] [env:gh_release]
extends = base extends = base
build_flags = build_flags =
${base.build_flags} ${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.crosspoint_version}\" -DCROSSPOINT_VERSION=\"${crosspoint.version}\"

View File

@ -46,6 +46,7 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, sleepScreenCoverMode); serialization::writePod(outputFile, sleepScreenCoverMode);
serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writeString(outputFile, std::string(opdsServerUrl));
serialization::writePod(outputFile, textAntiAliasing); serialization::writePod(outputFile, textAntiAliasing);
serialization::writePod(outputFile, hideBatteryPercentage);
serialization::writePod(outputFile, hyphenationEnabled); serialization::writePod(outputFile, hyphenationEnabled);
outputFile.close(); outputFile.close();
@ -111,6 +112,8 @@ bool CrossPointSettings::loadFromFile() {
} }
serialization::readPod(inputFile, textAntiAliasing); serialization::readPod(inputFile, textAntiAliasing);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, hideBatteryPercentage);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, hyphenationEnabled); serialization::readPod(inputFile, hyphenationEnabled);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
} while (false); } while (false);

View File

@ -52,6 +52,12 @@ class CrossPointSettings {
// E-ink refresh frequency (pages between full refreshes) // E-ink refresh frequency (pages between full refreshes)
enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 }; enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 };
// Short power button press actions
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2 };
// Hide battery percentage
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2 };
// Sleep screen settings // Sleep screen settings
uint8_t sleepScreen = DARK; uint8_t sleepScreen = DARK;
// Sleep screen cover mode settings // Sleep screen cover mode settings
@ -61,8 +67,8 @@ class CrossPointSettings {
// Text rendering settings // Text rendering settings
uint8_t extraParagraphSpacing = 1; uint8_t extraParagraphSpacing = 1;
uint8_t textAntiAliasing = 1; uint8_t textAntiAliasing = 1;
// Duration of the power button press // Short power button click behaviour
uint8_t shortPwrBtn = 0; uint8_t shortPwrBtn = IGNORE;
// EPUB reading orientation settings // EPUB reading orientation settings
// 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise // 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise
uint8_t orientation = PORTRAIT; uint8_t orientation = PORTRAIT;
@ -84,13 +90,17 @@ class CrossPointSettings {
uint8_t screenMargin = 5; uint8_t screenMargin = 5;
// OPDS browser settings // OPDS browser settings
char opdsServerUrl[128] = ""; char opdsServerUrl[128] = "";
// Hide battery percentage
uint8_t hideBatteryPercentage = HIDE_NEVER;
~CrossPointSettings() = default; ~CrossPointSettings() = default;
// Get singleton instance // Get singleton instance
static CrossPointSettings& getInstance() { return instance; } static CrossPointSettings& getInstance() { return instance; }
uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 400; } uint16_t getPowerButtonDuration() const {
return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? 10 : 400;
}
int getReaderFontId() const; int getReaderFontId() const;
bool saveToFile() const; bool saveToFile() const;

View File

@ -8,10 +8,11 @@
#include "Battery.h" #include "Battery.h"
#include "fontIds.h" #include "fontIds.h"
void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top) { void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top,
const bool showPercentage) {
// Left aligned battery icon and percentage // Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage(); const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%"; const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : "";
renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str()); renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body // 1 column on left, 2 columns on right, 5 columns of battery body

View File

@ -7,7 +7,7 @@ class GfxRenderer;
class ScreenComponents { class ScreenComponents {
public: public:
static void drawBattery(const GfxRenderer& renderer, int left, int top); static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true);
/** /**
* Draw a progress bar with percentage text. * Draw a progress bar with percentage text.

View File

@ -86,7 +86,7 @@ void SleepActivity::renderCustomSleepScreen() const {
if (SdMan.openFileForRead("SLP", filename, file)) { if (SdMan.openFileForRead("SLP", filename, file)) {
Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
delay(100); delay(100);
Bitmap bitmap(file); Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
renderBitmapSleepScreen(bitmap); renderBitmapSleepScreen(bitmap);
dir.close(); dir.close();
@ -101,7 +101,7 @@ void SleepActivity::renderCustomSleepScreen() const {
// render a custom sleep screen instead of the default. // render a custom sleep screen instead of the default.
FsFile file; FsFile file;
if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) { if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) {
Bitmap bitmap(file); Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis());
renderBitmapSleepScreen(bitmap); renderBitmapSleepScreen(bitmap);
@ -199,6 +199,7 @@ void SleepActivity::renderCoverSleepScreen() const {
} }
std::string coverBmpPath; std::string coverBmpPath;
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") || if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") ||
StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) { StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) {
@ -223,12 +224,12 @@ void SleepActivity::renderCoverSleepScreen() const {
return renderDefaultSleepScreen(); return renderDefaultSleepScreen();
} }
if (!lastEpub.generateCoverBmp()) { if (!lastEpub.generateCoverBmp(cropped)) {
Serial.println("[SLP] Failed to generate cover bmp"); Serial.println("[SLP] Failed to generate cover bmp");
return renderDefaultSleepScreen(); return renderDefaultSleepScreen();
} }
coverBmpPath = lastEpub.getCoverBmpPath(); coverBmpPath = lastEpub.getCoverBmpPath(cropped);
} else { } else {
return renderDefaultSleepScreen(); return renderDefaultSleepScreen();
} }

View File

@ -7,7 +7,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "ScreenComponents.h" #include "ScreenComponents.h"
#include "WifiCredentialStore.h" #include "activities/network/WifiSelectionActivity.h"
#include "fontIds.h" #include "fontIds.h"
#include "network/HttpDownloader.h" #include "network/HttpDownloader.h"
#include "util/StringUtils.h" #include "util/StringUtils.h"
@ -25,7 +25,7 @@ void OpdsBookBrowserActivity::taskTrampoline(void* param) {
} }
void OpdsBookBrowserActivity::onEnter() { void OpdsBookBrowserActivity::onEnter() {
Activity::onEnter(); ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
state = BrowserState::CHECK_WIFI; state = BrowserState::CHECK_WIFI;
@ -49,7 +49,7 @@ void OpdsBookBrowserActivity::onEnter() {
} }
void OpdsBookBrowserActivity::onExit() { void OpdsBookBrowserActivity::onExit() {
Activity::onExit(); ActivityWithSubactivity::onExit();
// Turn off WiFi when exiting // Turn off WiFi when exiting
WiFi.mode(WIFI_OFF); WiFi.mode(WIFI_OFF);
@ -66,13 +66,28 @@ void OpdsBookBrowserActivity::onExit() {
} }
void OpdsBookBrowserActivity::loop() { void OpdsBookBrowserActivity::loop() {
// Handle WiFi selection subactivity
if (state == BrowserState::WIFI_SELECTION) {
ActivityWithSubactivity::loop();
return;
}
// Handle error state - Confirm retries, Back goes back or home // Handle error state - Confirm retries, Back goes back or home
if (state == BrowserState::ERROR) { if (state == BrowserState::ERROR) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
state = BrowserState::LOADING; // Check if WiFi is still connected
statusMessage = "Loading..."; if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
updateRequired = true; // WiFi connected - just retry fetching the feed
fetchFeed(currentPath); Serial.printf("[%lu] [OPDS] Retry: WiFi connected, retrying fetch\n", millis());
state = BrowserState::LOADING;
statusMessage = "Loading...";
updateRequired = true;
fetchFeed(currentPath);
} else {
// WiFi not connected - launch WiFi selection
Serial.printf("[%lu] [OPDS] Retry: WiFi not connected, launching selection\n", millis());
launchWifiSelection();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
navigateBack(); navigateBack();
} }
@ -350,8 +365,8 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
} }
void OpdsBookBrowserActivity::checkAndConnectWifi() { void OpdsBookBrowserActivity::checkAndConnectWifi() {
// Already connected? // Already connected? Verify connection is valid by checking IP
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
state = BrowserState::LOADING; state = BrowserState::LOADING;
statusMessage = "Loading..."; statusMessage = "Loading...";
updateRequired = true; updateRequired = true;
@ -359,38 +374,33 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() {
return; return;
} }
// Try to connect using saved credentials // Not connected - launch WiFi selection screen directly
statusMessage = "Connecting to WiFi..."; launchWifiSelection();
}
void OpdsBookBrowserActivity::launchWifiSelection() {
state = BrowserState::WIFI_SELECTION;
updateRequired = true; updateRequired = true;
WIFI_STORE.loadFromFile(); enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
const auto& credentials = WIFI_STORE.getCredentials(); [this](const bool connected) { onWifiSelectionComplete(connected); }));
if (credentials.empty()) { }
state = BrowserState::ERROR;
errorMessage = "No WiFi credentials saved";
updateRequired = true;
return;
}
// Use the first saved credential void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) {
const auto& cred = credentials[0]; exitActivity();
WiFi.mode(WIFI_STA);
WiFi.begin(cred.ssid.c_str(), cred.password.c_str());
// Wait for connection with timeout if (connected) {
constexpr int WIFI_TIMEOUT_MS = 10000; Serial.printf("[%lu] [OPDS] WiFi connected via selection, fetching feed\n", millis());
const unsigned long startTime = millis();
while (WiFi.status() != WL_CONNECTED && millis() - startTime < WIFI_TIMEOUT_MS) {
vTaskDelay(100 / portTICK_PERIOD_MS);
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("[%lu] [OPDS] WiFi connected: %s\n", millis(), WiFi.localIP().toString().c_str());
state = BrowserState::LOADING; state = BrowserState::LOADING;
statusMessage = "Loading..."; statusMessage = "Loading...";
updateRequired = true; updateRequired = true;
fetchFeed(currentPath); fetchFeed(currentPath);
} else { } else {
Serial.printf("[%lu] [OPDS] WiFi selection cancelled/failed\n", millis());
// Force disconnect to ensure clean state for next retry
// This prevents stale connection status from interfering
WiFi.disconnect();
WiFi.mode(WIFI_OFF);
state = BrowserState::ERROR; state = BrowserState::ERROR;
errorMessage = "WiFi connection failed"; errorMessage = "WiFi connection failed";
updateRequired = true; updateRequired = true;

View File

@ -8,25 +8,27 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "../Activity.h" #include "../ActivityWithSubactivity.h"
/** /**
* Activity for browsing and downloading books from an OPDS server. * Activity for browsing and downloading books from an OPDS server.
* Supports navigation through catalog hierarchy and downloading EPUBs. * Supports navigation through catalog hierarchy and downloading EPUBs.
* When WiFi connection fails, launches WiFi selection to let user connect.
*/ */
class OpdsBookBrowserActivity final : public Activity { class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
public: public:
enum class BrowserState { enum class BrowserState {
CHECK_WIFI, // Checking WiFi connection CHECK_WIFI, // Checking WiFi connection
LOADING, // Fetching OPDS feed WIFI_SELECTION, // WiFi selection subactivity is active
BROWSING, // Displaying entries (navigation or books) LOADING, // Fetching OPDS feed
DOWNLOADING, // Downloading selected EPUB BROWSING, // Displaying entries (navigation or books)
ERROR // Error state with message DOWNLOADING, // Downloading selected EPUB
ERROR // Error state with message
}; };
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onGoHome) const std::function<void()>& onGoHome)
: Activity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {} : ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@ -54,6 +56,8 @@ class OpdsBookBrowserActivity final : public Activity {
void render() const; void render() const;
void checkAndConnectWifi(); void checkAndConnectWifi();
void launchWifiSelection();
void onWifiSelectionComplete(bool connected);
void fetchFeed(const std::string& path); void fetchFeed(const std::string& path);
void navigateToEntry(const OpdsEntry& entry); void navigateToEntry(const OpdsEntry& entry);
void navigateBack(); void navigateBack();

View File

@ -7,6 +7,7 @@
#include <cstring> #include <cstring>
#include <vector> #include <vector>
#include "Battery.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
@ -68,7 +69,7 @@ void HomeActivity::onEnter() {
updateRequired = true; updateRequired = true;
xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask",
2048, // Stack size 4096, // Stack size
this, // Parameters this, // Parameters
1, // Priority 1, // Priority
&displayTaskHandle // Task handle &displayTaskHandle // Task handle
@ -332,8 +333,13 @@ void HomeActivity::render() const {
const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down"); const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, "100 %"); const bool showBatteryPercentage =
ScreenComponents::drawBattery(renderer, batteryX, 10); SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
// get percentage so we can align text properly
const uint16_t percentage = battery.readPercentage();
const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : "";
const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage);
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -152,6 +152,8 @@ void EpubReaderActivity::loop() {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left); mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
(SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
mappedInput.wasReleased(MappedInputManager::Button::Power)) ||
mappedInput.wasReleased(MappedInputManager::Button::Right); mappedInput.wasReleased(MappedInputManager::Button::Right);
if (!prevReleased && !nextReleased) { if (!prevReleased && !nextReleased) {
@ -417,6 +419,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
// Position status bar near the bottom of the logical screen, regardless of orientation // Position status bar near the bottom of the logical screen, regardless of orientation
const auto screenHeight = renderer.getScreenHeight(); const auto screenHeight = renderer.getScreenHeight();
@ -437,7 +441,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
} }
if (showBattery) { if (showBattery) {
ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY); ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage);
} }
if (showChapterTitle) { if (showChapterTitle) {

View File

@ -16,7 +16,9 @@ int EpubReaderChapterSelectionActivity::getPageItems() const {
constexpr int lineHeight = 30; constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight(); const int screenHeight = renderer.getScreenHeight();
const int availableHeight = screenHeight - startY; const int endY = screenHeight - lineHeight;
const int availableHeight = endY - startY;
int items = availableHeight / lineHeight; int items = availableHeight / lineHeight;
// Ensure we always have at least one item per page to avoid division by zero // Ensure we always have at least one item per page to avoid division by zero
@ -134,5 +136,8 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
tocIndex != selectorIndex); tocIndex != selectorIndex);
} }
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -112,6 +112,8 @@ void XtcReaderActivity::loop() {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left); mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
(SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
mappedInput.wasReleased(MappedInputManager::Button::Power)) ||
mappedInput.wasReleased(MappedInputManager::Button::Right); mappedInput.wasReleased(MappedInputManager::Button::Right);
if (!prevReleased && !nextReleased) { if (!prevReleased && !nextReleased) {

View File

@ -14,7 +14,9 @@ int XtcReaderChapterSelectionActivity::getPageItems() const {
constexpr int lineHeight = 30; constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight(); const int screenHeight = renderer.getScreenHeight();
const int availableHeight = screenHeight - startY; const int endY = screenHeight - lineHeight;
const int availableHeight = endY - startY;
int items = availableHeight / lineHeight; int items = availableHeight / lineHeight;
if (items < 1) { if (items < 1) {
items = 1; items = 1;
@ -147,5 +149,8 @@ void XtcReaderChapterSelectionActivity::renderScreen() {
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex); renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex);
} }
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -19,9 +19,10 @@ const SettingInfo settingsList[settingsCount] = {
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}),
SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}),
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing),
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing), SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing),
SettingInfo::Toggle("Short Power Button Click", &CrossPointSettings::shortPwrBtn), SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"}),
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout, SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout,