Compare commits

...

15 Commits

Author SHA1 Message Date
Eunchurn Park
b5d92e71f7
Merge upstream/master into feature/continue-reading-cover
Resolved merge conflicts:
- lib/Epub/Epub.h: Kept both upstream's cropped parameter and HEAD's thumb functions
- lib/JpegToBmpConverter/JpegToBmpConverter.cpp: Used upstream's version (dithering code moved to BitmapHelpers)
- src/activities/home/HomeActivity.cpp: Kept HEAD's comment about stack size

Added missing 1-bit dithering support to BitmapHelpers:
- Added Atkinson1BitDitherer class to BitmapHelpers.h
- Added quantize1bit() function to BitmapHelpers.cpp
2026-01-14 02:24:36 +09:00
Dave Allie
52995fa722
chore: Cut release 0.13.1
Some checks failed
CI / build (push) Has been cancelled
2026-01-13 02:09:39 +11:00
Dave Allie
d4f8eda154
fix: Increase home activity stack size (#333)
## Summary

* fix: Increase home activity stack size

## Additional Context

* Home activity can crash occasionally depending on book

---

### 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? No
2026-01-13 02:09:06 +11:00
Dave Allie
33b8fa0e19
chore: Cut release 0.13.0 2026-01-13 00:59:13 +11:00
Dave Allie
16c760b2d2
copy: Tweak pull request template wording 2026-01-13 00:59:04 +11:00
Dave Allie
8f3df7e10e
fix: Handle EPUB 3 TOC to spine mapping when nav file in subdirectory (#332)
## Summary

- Nav file in EPUB 3 file is a HTML file with relative hrefs
- If this file exists anywhere but in the same location as the
content.opf file, navigating in the book will fail
- Bump the book cache version to rebuild potentially broken books

## Additional Context

- Fixes https://github.com/daveallie/crosspoint-reader/issues/264

---

### 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
- [x] No
2026-01-13 00:57:34 +11:00
Jonas Diemer
0165fab581
Fix BMP rendering gamma/brightness (#302)
1. Refactor Bitmap.cpp/h to expose the options for FloydSteinberg and
brightness/gamma correction at runtime
2. Fine-tune the thresholds for Floyd Steiberg and simple quantization
to better match the display's colors

Turns out that 2 is enough to make the images render properly, so the
brightness boost and gamma adjustment doesn't seem necessary currently
(at least for my test image).
2026-01-12 22:36:19 +11:00
danoob
66b100c6ca
fix: Wi-Fi Selection on Calibre Library launch (#313)
## Summary

* **What is the goal of this PR?** 
Fixes the Wi-Fi connection issue when launching the Calibre Library
(OPDS browser). The previous implementation always attempted to connect
using the first saved WiFi credential, which caused connection failures
when users were in locations where only other saved networks (not the
first one) were available. Now, the activity launches a WiFi selection
screen allowing users to choose from available networks.

* **What changes are included?**

## Additional Context
**Bug Fixed**: Previously, the code used `credentials[0]` (always the
first saved WiFi), so users in areas with only their secondary/tertiary
saved networks available could never connect.

---------

Co-authored-by: danoooob <danoooob@example.com>
2026-01-12 21:49:42 +11:00
Andrew Brandt
41bda43899
chore: update formatting in github workflows (#320)
**Description**:

The purpose of this change is to modify the spacing in the
`.github/workflow` files to ensure consistency.

**Related Issue(s)**:

Implements #319

Signed-off-by: Andrew Brandt <brandt.andrew89@gmail.com>
2026-01-12 21:37:23 +11:00
Dave Allie
82f21f3c1d
Add AI usage question to the PR template 2026-01-12 21:35:18 +11:00
Jonas Diemer
a9242fe61f
Generate different .bmp for cropped covers so settings have effect. (#330)
Addresses
https://github.com/daveallie/crosspoint-reader/pull/225#issuecomment-3735150337
2026-01-12 20:55:47 +11:00
Jonas Diemer
88d0d90471
Add option to hide battery percentage. (#297)
with option to always hide or hide in reader only.

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-12 20:53:58 +11:00
David Fischer
97c4871316
Add page turn on power button press (#286)
## Summary

* **What is the goal of this PR?** 
* This PR adds a setting to (additionally) map the forward page turn
onto the powerbutton when in `EPUBReaderActivity` and powerbutton short
press is not mapped to sleep mode. I find the powerbutton to be exactly
where my thumb is while reading so it is very convenient to map the
forwardpage turn to that. Maybe Im not alone with this ^^
* **What changes are included?**

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
2026-01-12 20:07:26 +11:00
Seth
66811bf50b
Add navigation hints to ChapterSelectionActivities (#294)
## Summary

Add navigation hints to Chapter Select - #190 

### Before

![Mi 11X_20260108_214114_lmc_8
4](https://github.com/user-attachments/assets/45031d21-2c6c-4b7d-a5cc-6ad111bf5a70)

### After
![Mi 11X_20260108_213803_lmc_8
4](https://github.com/user-attachments/assets/1fa4ef22-63e4-4adb-8fc5-5fb8c7fa79fa)
2026-01-12 19:59:02 +11:00
Samuel Carpentier
87287012ba
Updated user guide (sleep screens list) (#293)
## Summary

Updated sleep screens list by adding the newly available "Blank" option
2026-01-12 19:58:08 +11:00
29 changed files with 617 additions and 580 deletions

View File

@ -1,9 +1,18 @@
## Summary
* **What is the goal of this PR?** (e.g., Fixes a bug in the user authentication module, Implements the new feature for
file uploading.)
* **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.)
* **What changes are included?**
## 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:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: '3.14'

View File

@ -7,17 +7,18 @@ on:
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: actions/cache@v5
with:
path: |
~/.cache/pip
~/.platformio/.cache
key: ${{ runner.os }}-pio
- uses: actions/setup-python@v6
with:
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
- "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)
- "Blank" - A blank screen
- **Status Bar**: Configure the status bar displayed while reading:
- "None" - No status bar
- "No Progress" - Show status bar without reading progress

View File

@ -167,7 +167,10 @@ bool Epub::parseTocNavFile() const {
}
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()) {
Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis());
@ -345,11 +348,14 @@ const std::string& Epub::getAuthor() const {
return bookMetadataCache->coreMetadata.author;
}
std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
std::string Epub::getCoverBmpPath(bool cropped) const {
const auto coverFileName = "cover" + cropped ? "_crop" : "";
return cachePath + "/" + coverFileName + ".bmp";
}
bool Epub::generateCoverBmp() const {
bool Epub::generateCoverBmp(bool cropped) const {
// Already generated, return true
if (SdMan.exists(getCoverBmpPath().c_str())) {
if (SdMan.exists(getCoverBmpPath(cropped).c_str())) {
return true;
}
@ -381,7 +387,7 @@ bool Epub::generateCoverBmp() const {
}
FsFile coverBmp;
if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) {
if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
coverJpg.close();
return false;
}
@ -392,7 +398,7 @@ bool Epub::generateCoverBmp() const {
if (!success) {
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");
return success;

View File

@ -44,8 +44,8 @@ class Epub {
const std::string& getPath() const;
const std::string& getTitle() const;
const std::string& getAuthor() const;
std::string getCoverBmpPath() const;
bool generateCoverBmp() const;
std::string getCoverBmpPath(bool cropped = false) const;
bool generateCoverBmp(bool cropped = false) const;
std::string getThumbBmpPath() const;
bool generateThumbBmp() const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,

View File

@ -9,7 +9,7 @@
#include "FsHelpers.h"
namespace {
constexpr uint8_t BOOK_CACHE_VERSION = 3;
constexpr uint8_t BOOK_CACHE_VERSION = 4;
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";

View File

@ -167,7 +167,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (strcmp(atts[i], "id") == 0) {
itemId = atts[i + 1];
} 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) {
mediaType = atts[i + 1];
} else if (strcmp(atts[i], "properties") == 0) {
@ -243,7 +243,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
break;
}
} 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)) {

View File

@ -1,5 +1,6 @@
#include "TocNavParser.h"
#include <FsHelpers.h>
#include <HardwareSerial.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) {
// Create TOC entry when closing anchor tag (we have all data now)
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;
const size_t pos = href.find('#');

View File

@ -1,5 +1,6 @@
#include "TocNcxParser.h"
#include <FsHelpers.h>
#include <HardwareSerial.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>.
// NCX spec says navLabel comes before content.
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;
const size_t pos = href.find('#');

View File

@ -8,119 +8,15 @@
// ============================================================================
// Note: For cover images, dithering is done in JpegToBmpConverter.cpp
// 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_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
constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg
// ============================================================================
// 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() {
delete[] errorCurRow;
delete[] errorNextRow;
delete atkinsonDitherer;
delete fsDitherer;
}
uint16_t Bitmap::readLE16(FsFile& f) {
@ -244,13 +140,14 @@ BmpReaderError Bitmap::parseHeaders() {
return BmpReaderError::SeekPixelDataFailed;
}
// Allocate Floyd-Steinberg error buffers if enabled
if (USE_FLOYD_STEINBERG) {
delete[] errorCurRow;
delete[] errorNextRow;
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling
errorNextRow = new int16_t[width + 2]();
prevRowY = -1;
// Create ditherer if enabled (only for 2-bit output)
// Use OUTPUT dimensions for dithering (after prescaling)
if (bpp > 2 && dithering) {
if (USE_ATKINSON) {
atkinsonDitherer = new AtkinsonDitherer(width);
} else {
fsDitherer = new FloydSteinbergDitherer(width);
}
}
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'
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;
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
auto packPixel = [&](const uint8_t lum) {
uint8_t color;
if (useFS) {
// Floyd-Steinberg error diffusion
color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false);
if (atkinsonDitherer) {
color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX);
} else if (fsDitherer) {
color = fsDitherer->processPixel(adjustPixel(lum), currentX);
} else {
// Simple quantization or noise dithering
color = quantize(lum, currentX, prevRowY);
if (bpp > 2) {
// 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);
if (bitShift == 0) {
@ -348,6 +240,11 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
return BmpReaderError::UnsupportedBpp;
}
if (atkinsonDitherer)
atkinsonDitherer->nextRow();
else if (fsDitherer)
fsDitherer->nextRow();
// Flush remaining bits if width is not a multiple of 4
if (bitShift != 6) *outPtr = currentOutByte;
@ -359,12 +256,9 @@ BmpReaderError Bitmap::rewindToData() const {
return BmpReaderError::SeekPixelDataFailed;
}
// Reset Floyd-Steinberg error buffers when rewinding
if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
prevRowY = -1;
}
// Reset dithering when rewinding
if (fsDitherer) fsDitherer->reset();
if (atkinsonDitherer) atkinsonDitherer->reset();
return BmpReaderError::Ok;
}

View File

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

View File

@ -0,0 +1,106 @@
#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);
}
}
// 1-bit noise dithering for fast home screen rendering
// Uses hash-based noise for consistent dithering that works well at small sizes
uint8_t quantize1bit(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
// Simple threshold with noise: gray >= (128 + noise offset) -> white
// The noise adds variation around the 128 midpoint
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
return (gray >= adjustedThreshold) ? 1 : 0;
}

View File

@ -0,0 +1,314 @@
#pragma once
#include <cstring>
// Helper functions
uint8_t quantize(int gray, int x, int y);
uint8_t quantizeSimple(int gray);
uint8_t quantize1bit(int gray, int x, int y);
int adjustPixel(int gray);
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):
// X 1/8 1/8
// 1/8 1/8 1/8
// 1/8
class Atkinson1BitDitherer {
public:
explicit Atkinson1BitDitherer(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
}
~Atkinson1BitDitherer() {
delete[] errorRow0;
delete[] errorRow1;
delete[] errorRow2;
}
// EXPLICITLY DELETE THE COPY CONSTRUCTOR
Atkinson1BitDitherer(const Atkinson1BitDitherer& other) = delete;
// EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR
Atkinson1BitDitherer& operator=(const Atkinson1BitDitherer& other) = delete;
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 2 levels (1-bit): 0 = black, 1 = white
uint8_t quantized;
int quantizedValue;
if (adjusted < 128) {
quantized = 0;
quantizedValue = 0;
} else {
quantized = 1;
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;
};
// 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 <cstring>
#include "BitmapHelpers.h"
// Context structure for picojpeg callback
struct JpegReadContext {
FsFile& file;
@ -23,372 +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_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts)
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)
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_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);
}
}
// 1-bit noise dithering for fast home screen rendering
// Uses hash-based noise for consistent dithering that works well at small sizes
static inline uint8_t quantize1bit(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
// Simple threshold with noise: gray >= (128 + noise offset) -> white
// The noise adds variation around the 128 midpoint
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
return (gray >= adjustedThreshold) ? 1 : 0;
}
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):
// X 1/8 1/8
// 1/8 1/8 1/8
// 1/8
class Atkinson1BitDitherer {
public:
Atkinson1BitDitherer(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
}
~Atkinson1BitDitherer() {
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 2 levels (1-bit): 0 = black, 1 = white
uint8_t quantized;
int quantizedValue;
if (adjusted < 128) {
quantized = 0;
quantizedValue = 0;
} else {
quantized = 1;
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;
};
// 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) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
@ -775,12 +417,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
} else {
// 2-bit output
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;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow());
twoBit = fsDitherer->processPixel(gray, x);
} else {
twoBit = quantize(gray, x, y);
}
@ -851,12 +493,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
} else {
// 2-bit output
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;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow());
twoBit = fsDitherer->processPixel(gray, x);
} else {
twoBit = quantize(gray, x, currentOutY);
}

View File

@ -1,7 +1,9 @@
[platformio]
crosspoint_version = 0.12.0
default_envs = default
[crosspoint]
version = 0.13.1
[base]
platform = espressif32 @ 6.12.0
board = esp32-c3-devkitm-1
@ -50,10 +52,10 @@ lib_deps =
extends = base
build_flags =
${base.build_flags}
-DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}-dev\"
-DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\"
[env:gh_release]
extends = base
build_flags =
${base.build_flags}
-DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}\"
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"

View File

@ -46,6 +46,7 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, sleepScreenCoverMode);
serialization::writeString(outputFile, std::string(opdsServerUrl));
serialization::writePod(outputFile, textAntiAliasing);
serialization::writePod(outputFile, hideBatteryPercentage);
outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@ -110,6 +111,8 @@ bool CrossPointSettings::loadFromFile() {
}
serialization::readPod(inputFile, textAntiAliasing);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, hideBatteryPercentage);
if (++settingsRead >= fileSettingsCount) break;
} while (false);
inputFile.close();

View File

@ -52,6 +52,12 @@ class CrossPointSettings {
// 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 };
// 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
uint8_t sleepScreen = DARK;
// Sleep screen cover mode settings
@ -61,8 +67,8 @@ class CrossPointSettings {
// Text rendering settings
uint8_t extraParagraphSpacing = 1;
uint8_t textAntiAliasing = 1;
// Duration of the power button press
uint8_t shortPwrBtn = 0;
// Short power button click behaviour
uint8_t shortPwrBtn = IGNORE;
// EPUB reading orientation settings
// 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise
uint8_t orientation = PORTRAIT;
@ -82,13 +88,17 @@ class CrossPointSettings {
uint8_t screenMargin = 5;
// OPDS browser settings
char opdsServerUrl[128] = "";
// Hide battery percentage
uint8_t hideBatteryPercentage = HIDE_NEVER;
~CrossPointSettings() = default;
// Get singleton 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;
bool saveToFile() const;

View File

@ -8,10 +8,11 @@
#include "Battery.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
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());
// 1 column on left, 2 columns on right, 5 columns of battery body

View File

@ -7,7 +7,7 @@ class GfxRenderer;
class ScreenComponents {
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.

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@
#include <cstring>
#include <vector>
#include "Battery.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
@ -562,8 +563,13 @@ void HomeActivity::render() {
const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down");
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 %");
ScreenComponents::drawBattery(renderer, batteryX, 10);
const bool showBatteryPercentage =
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();
}

View File

@ -152,6 +152,8 @@ void EpubReaderActivity::loop() {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
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);
if (!prevReleased && !nextReleased) {
@ -417,6 +419,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
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
const auto screenHeight = renderer.getScreenHeight();
@ -437,7 +441,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
}
if (showBattery) {
ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY);
ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage);
}
if (showChapterTitle) {

View File

@ -16,7 +16,9 @@ int EpubReaderChapterSelectionActivity::getPageItems() const {
constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight();
const int availableHeight = screenHeight - startY;
const int endY = screenHeight - lineHeight;
const int availableHeight = endY - startY;
int items = availableHeight / lineHeight;
// Ensure we always have at least one item per page to avoid division by zero
@ -134,5 +136,8 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
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();
}

View File

@ -112,6 +112,8 @@ void XtcReaderActivity::loop() {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
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);
if (!prevReleased && !nextReleased) {

View File

@ -14,7 +14,9 @@ int XtcReaderChapterSelectionActivity::getPageItems() const {
constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight();
const int availableHeight = screenHeight - startY;
const int endY = screenHeight - lineHeight;
const int availableHeight = endY - startY;
int items = availableHeight / lineHeight;
if (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);
}
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();
}

View File

@ -13,15 +13,16 @@
// Define the static settings list
namespace {
constexpr int settingsCount = 18;
constexpr int settingsCount = 19;
const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}),
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("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,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout,