Compare commits

..

42 Commits

Author SHA1 Message Date
Dave Allie 45af2d0e81 Cut release 0.3.0 2025-12-08 23:55:05 +11:00
Dave Allie 0926e9e6e4 Add version string to boot screen 2025-12-08 23:13:33 +11:00
Dave Allie 02b157c02b Add drawCenteredText to GfxRenderer 2025-12-08 22:52:19 +11:00
Dave Allie 07cc589e59 Cleanup serial output 2025-12-08 22:39:23 +11:00
Dave Allie b743a1ca8e Remove EpdRenderer and create new GfxRenderer 2025-12-08 22:06:09 +11:00
Dave Allie 2ed8017aa2 Move to SDK EInkDisplay and enable anti-aliased 2-bit text (#5)
* First pass at moving to SDK EInkDisplay library

* Add 2-bit grayscale text and anti-aliased rendering of text

* Render status bar for empty chapters

* Refresh screen every 15 pages to avoid ghosting

* Simplify boot and sleep screens

* Give FileSelectionScreen task more stack memory

* Move text around slightly on Boot and Sleep screens

* Re-use existing buffer and write to whole screen for 'partial update'
2025-12-08 19:48:49 +11:00
Dave Allie de453fed1d Stream inflated EPUB HTMLs down to disk instead of inflating in memory (#4)
* Downgrade miniz for stability

* Stream HTML from ZIP down to disk instead of loading all in mem
2025-12-08 00:39:17 +11:00
Dave Allie c715c18bf7 Sort directories first in file picker 2025-12-07 19:17:43 +11:00
Dave Allie 9fa697ae0c Fix 1-bit font pixel alignment of last byte 2025-12-07 12:25:10 +11:00
Dave Allie 79294f6b8f Build and use 1-bit font, saves a good amount of space 2025-12-07 01:26:49 +11:00
Dave Allie eceffaa289 Reduce number of full screen refreshes to once every 20 pages 2025-12-06 22:12:01 +11:00
Dave Allie bb151caee7 Version section bin files 2025-12-06 22:09:25 +11:00
Dave Allie dd6e649d74 Add expat and swap out EPUB HTML parser (#2)
* Add expat and swap out ERB HTML parser

* Increase EpubHtmlParserSlim file buffer to 1024 bytes

* Cleanup TextBlock functions

* Do not break words when leaving spans
2025-12-06 20:57:24 +11:00
Dave Allie ad8cee12ab Small cleanup 2025-12-06 20:24:24 +11:00
Dave Allie 4ecfdea1a1 More pass by reference changes 2025-12-06 15:56:00 +11:00
Dave Allie 5ed2fe391d Add image to README 2025-12-06 13:32:09 +11:00
Dave Allie 021f77eab3 Sort items on FileSelectionScreen 2025-12-06 13:01:16 +11:00
Dave Allie 6d3d25a288 Fix bug with selectin epubs inside of folders 2025-12-06 12:57:17 +11:00
Dave Allie 9a33030623 Use reference passing for EpdRenderer 2025-12-06 12:56:39 +11:00
Dave Allie 6414f85257 Use InputManager from community-sdk 2025-12-06 12:35:41 +11:00
Dave Allie f0d92da8f2 Update README.md with checkout instructions
Fixes https://github.com/daveallie/crosspoint-reader/issues/1
2025-12-06 04:53:58 +11:00
Dave Allie 8679c8f57c Update sleep screen 2025-12-06 04:20:41 +11:00
Dave Allie 899caab70c Avoid leaving screens mid-display update
Was leave the EPD in a bad state, blocking further actions
2025-12-06 03:02:52 +11:00
Dave Allie 98c8e7e77c Fix memory leak with Epub object getting orphaned 2025-12-06 02:49:10 +11:00
Dave Allie 7198d943b0 Add UI font 2025-12-06 01:44:14 +11:00
Dave Allie 248af4b8fb Add new boot logo screen 2025-12-06 01:44:14 +11:00
Dave Allie 05a027e2bf Wrap up multiple font styles into EpdFontFamily 2025-12-06 01:44:14 +11:00
Dave Allie fa0f27df6a Full screen refresh of EpubReaderScreen every 10 renders
This is done in an effort to minimise ghosting
2025-12-05 22:19:44 +11:00
Dave Allie 2631613b8d Add directory picking to home screen 2025-12-05 22:12:28 +11:00
Dave Allie 72aa7ba3f6 Upgrade open-x4-sdk 2025-12-05 21:14:08 +11:00
Dave Allie e08bac2e10 Show indexing text when indexing 2025-12-05 21:12:15 +11:00
Dave Allie 12d28e2148 Avoid ghosting on sleep screen by doing a full screen update 2025-12-05 17:55:17 +11:00
Dave Allie 85502b417e Speedup boot by not waiting for Serial 2025-12-05 17:47:23 +11:00
Dave Allie ddec7f78dd Fix hold to wake logic
esp_sleep_get_wakeup_cause does not seem to be set when not connected to USB power
2025-12-04 00:57:32 +11:00
Dave Allie 2f9f86b3dd Update README.md features 2025-12-04 00:14:47 +11:00
Dave Allie 47eb1157ef Support left and right buttons in reader and file picker 2025-12-04 00:11:51 +11:00
Dave Allie aee239a931 Adjust input button thresholds to support more devices 2025-12-04 00:11:51 +11:00
Dave Allie 1ee8b728f9 Add file selection screen 2025-12-04 00:11:51 +11:00
Dave Allie 2c80aca7b5 Use correct current page on reader screen
Fixes the page counter not updating
2025-12-03 22:34:16 +11:00
Dave Allie 7704772ebe Handle nested navpoint elements in nxc TOC 2025-12-03 22:30:50 +11:00
Dave Allie 4186c7da9e Remove debug lines 2025-12-03 22:30:13 +11:00
Dave Allie 802c9d0a30 Add web flashing instructions 2025-12-03 22:21:11 +11:00
85 changed files with 31245 additions and 23234 deletions
+30 -5
View File
@@ -6,10 +6,7 @@ Built using **PlatformIO** and targeting the **ESP32-C3** microcontroller.
CrossPoint Reader is a purpose-built firmware designed to be a drop-in, fully open-source replacement for the official
Xteink firmware. It aims to match or improve upon the standard EPUB reading experience.
// TODO include some images
I look at the [**diy-esp32-epub-reader** by atomic14](https://github.com/atomic14/diy-esp32-epub-reader) project a lot
when making CrossPoint and a handful of lessons and some direct source code comes directly from that repo.
![](./docs/cover.jpg)
## Motivation
@@ -31,7 +28,9 @@ This project is **not affiliated with Xteink**; it's built as a community projec
- [x] EPUB parsing and rendering
- [x] Saved reading position
- [ ] File explorer with file picker
- Currently CrossPoint will just open the first EPUB it finds at the root of the SD card
- [x] Basic EPUB picker from root directory
- [x] Support nested folders
- [ ] EPUB picker with cover art
- [ ] Image support within EPUB
- [ ] Configurable font, layout, and display options
- [ ] WiFi connectivity
@@ -46,14 +45,37 @@ This project is **not affiliated with Xteink**; it's built as a community projec
* USB-C cable for flashing the ESP32-C3
* Xteink X4
### Checking out the code
CrossPoint uses PlatformIO for building and flashing the firmware. To get started, clone the repository:
```
git clone --recursive https://github.com/daveallie/crosspoint-reader
# Or, if you've already cloned without --recursive:
git submodule update --init --recursive
```
### Flashing your device
#### Command line
Connect your Xteink X4 to your computer via USB-C and run the following command.
```sh
pio run --target upload
```
#### Web
1. Connect your Xteink X4 to your computer via USB-C
2. Download the `firmware.bin` file from the latest release via the [releases page](https://github.com/daveallie/crosspoint-reader/releases)
3. Go to https://xteink.dve.al/ and flash the firmware file using the "OTA fast flash controls" section
4. Press the reset button on the Xteink X4 to restart the device
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug.
## Internals
CrossPoint Reader is pretty aggressive about caching data down to the SD card to minimise RAM usage. The ESP32-C3 only
@@ -104,3 +126,6 @@ Contributions are very welcome!
---
CrossPoint Reader is **not affiliated with Xteink or any manufacturer of the X4 hardware**.
Huge shoutout to [**diy-esp32-epub-reader** by atomic14](https://github.com/atomic14/diy-esp32-epub-reader), which was a project I took a lot of inspiration from as I
was making CrossPoint.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

-3
View File
@@ -2,12 +2,9 @@
#include <Utf8.h>
EpdFont::EpdFont(const EpdFontData* data) { this->data = data; }
inline int min(const int a, const int b) { return a < b ? a : b; }
inline int max(const int a, const int b) { return a < b ? b : a; }
// TODO: Text properties??
void EpdFont::getTextBounds(const char* string, const int startX, const int startY, int* minX, int* minY, int* maxX,
int* maxY) const {
*minX = startX;
+1 -1
View File
@@ -6,7 +6,7 @@ class EpdFont {
public:
const EpdFontData* data;
explicit EpdFont(const EpdFontData* data);
explicit EpdFont(const EpdFontData* data) : data(data) {}
~EpdFont() = default;
void getTextDimensions(const char* string, int* w, int* h) const;
bool hasPrintableChars(const char* string) const;
+8 -8
View File
@@ -6,13 +6,13 @@
/// Font data stored PER GLYPH
typedef struct {
uint8_t width; ///< Bitmap dimensions in pixels
uint8_t height; ///< Bitmap dimensions in pixels
uint8_t advanceX; ///< Distance to advance cursor (x axis)
int16_t left; ///< X dist from cursor pos to UL corner
int16_t top; ///< Y dist from cursor pos to UL corner
uint16_t compressedSize; ///< Size of the zlib-compressed font data.
uint32_t dataOffset; ///< Pointer into EpdFont->bitmap
uint8_t width; ///< Bitmap dimensions in pixels
uint8_t height; ///< Bitmap dimensions in pixels
uint8_t advanceX; ///< Distance to advance cursor (x axis)
int16_t left; ///< X dist from cursor pos to UL corner
int16_t top; ///< Y dist from cursor pos to UL corner
uint16_t dataLength; ///< Size of the font data.
uint32_t dataOffset; ///< Pointer into EpdFont->bitmap
} EpdGlyph;
/// Glyph interval structure
@@ -28,8 +28,8 @@ typedef struct {
const EpdGlyph* glyph; ///< Glyph array
const EpdUnicodeInterval* intervals; ///< Valid unicode intervals for this font
uint32_t intervalCount; ///< Number of unicode intervals.
bool compressed; ///< Does this font use compressed glyph bitmaps?
uint8_t advanceY; ///< Newline distance (y axis)
int ascender; ///< Maximal height of a glyph above the base line
int descender; ///< Maximal height of a glyph below the base line
bool is2Bit;
} EpdFontData;
+37
View File
@@ -0,0 +1,37 @@
#include "EpdFontFamily.h"
const EpdFont* EpdFontFamily::getFont(const EpdFontStyle style) const {
if (style == BOLD && bold) {
return bold;
}
if (style == ITALIC && italic) {
return italic;
}
if (style == BOLD_ITALIC) {
if (boldItalic) {
return boldItalic;
}
if (bold) {
return bold;
}
if (italic) {
return italic;
}
}
return regular;
}
void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const EpdFontStyle style) const {
getFont(style)->getTextDimensions(string, w, h);
}
bool EpdFontFamily::hasPrintableChars(const char* string, const EpdFontStyle style) const {
return getFont(style)->hasPrintableChars(string);
}
const EpdFontData* EpdFontFamily::getData(const EpdFontStyle style) const { return getFont(style)->data; }
const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const EpdFontStyle style) const {
return getFont(style)->getGlyph(cp);
};
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "EpdFont.h"
enum EpdFontStyle { REGULAR, BOLD, ITALIC, BOLD_ITALIC };
class EpdFontFamily {
const EpdFont* regular;
const EpdFont* bold;
const EpdFont* italic;
const EpdFont* boldItalic;
const EpdFont* getFont(EpdFontStyle style) const;
public:
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
const EpdFont* boldItalic = nullptr)
: regular(regular), bold(bold), italic(italic), boldItalic(boldItalic) {}
~EpdFontFamily() = default;
void getTextDimensions(const char* string, int* w, int* h, EpdFontStyle style = REGULAR) const;
bool hasPrintableChars(const char* string, EpdFontStyle style = REGULAR) const;
const EpdFontData* getData(EpdFontStyle style = REGULAR) const;
const EpdGlyph* getGlyph(uint32_t cp, EpdFontStyle style = REGULAR) const;
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+82 -22
View File
@@ -7,20 +7,20 @@ import math
import argparse
from collections import namedtuple
# From: https://github.com/vroland/epdiy
# Originally from https://github.com/vroland/epdiy
parser = argparse.ArgumentParser(description="Generate a header file from a font to be used with epdiy.")
parser.add_argument("name", action="store", help="name of the font.")
parser.add_argument("size", type=int, help="font size to use.")
parser.add_argument("fontstack", action="store", nargs='+', help="list of font files, ordered by descending priority.")
parser.add_argument("--compress", dest="compress", action="store_true", help="compress glyph bitmaps.")
parser.add_argument("--2bit", dest="is2Bit", action="store_true", help="generate 2-bit greyscale bitmap instead of 1-bit black and white.")
parser.add_argument("--additional-intervals", dest="additional_intervals", action="append", help="Additional code point intervals to export as min,max. This argument can be repeated.")
args = parser.parse_args()
GlyphProps = namedtuple("GlyphProps", ["width", "height", "advance_x", "left", "top", "compressed_size", "data_offset", "code_point"])
GlyphProps = namedtuple("GlyphProps", ["width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"])
font_stack = [freetype.Face(f) for f in args.fontstack]
compress = args.compress
is2Bit = args.is2Bit
size = args.size
font_name = args.name
@@ -148,19 +148,18 @@ for i_start, i_end in unvalidated_intervals:
intervals.append((start, i_end))
for face in font_stack:
# shift by 6 bytes, because sizes are given as 6-bit fractions
# the display has about 150 dpi.
face.set_char_size(size << 6, size << 6, 150, 150)
total_size = 0
total_packed = 0
all_glyphs = []
for i_start, i_end in intervals:
for code_point in range(i_start, i_end + 1):
face = load_glyph(code_point)
bitmap = face.glyph.bitmap
pixels = []
# Build out 4-bit greyscale bitmap
pixels4g = []
px = 0
for i, v in enumerate(bitmap.buffer):
y = i / bitmap.width
@@ -169,31 +168,92 @@ for i_start, i_end in intervals:
px = (v >> 4)
else:
px = px | (v & 0xF0)
pixels.append(px);
pixels4g.append(px);
px = 0
# eol
if x == bitmap.width - 1 and bitmap.width % 2 > 0:
pixels.append(px)
pixels4g.append(px)
px = 0
packed = bytes(pixels);
total_packed += len(packed)
compressed = packed
if compress:
compressed = zlib.compress(packed)
if is2Bit:
# 0 = white, 15 black, 8+ dark grey, 7- light grey
# Downsample to 2-bit bitmap
pixels2b = []
px = 0
pitch = (bitmap.width // 2) + (bitmap.width % 2)
for y in range(bitmap.rows):
for x in range(bitmap.width):
px = px << 2
bm = pixels4g[y * pitch + (x // 2)]
bm = (bm >> ((x % 2) * 4)) & 0xF
if bm == 15:
px += 3
elif bm >= 8:
px += 2
elif bm > 0:
px += 1
if (y * bitmap.width + x) % 4 == 3:
pixels2b.append(px)
px = 0
if (bitmap.width * bitmap.rows) % 4 != 0:
px = px << (4 - (bitmap.width * bitmap.rows) % 4) * 2
pixels2b.append(px)
# for y in range(bitmap.rows):
# line = ''
# for x in range(bitmap.width):
# pixelPosition = y * bitmap.width + x
# byte = pixels2b[pixelPosition // 4]
# bit_index = (3 - (pixelPosition % 4)) * 2
# line += '#' if ((byte >> bit_index) & 3) > 0 else '.'
# print(line)
# print('')
else:
# Downsample to 1-bit bitmap - treat any non-zero as black
pixelsbw = []
px = 0
pitch = (bitmap.width // 2) + (bitmap.width % 2)
for y in range(bitmap.rows):
for x in range(bitmap.width):
px = px << 1
bm = pixels4g[y * pitch + (x // 2)]
px += 1 if ((x & 1) == 0 and bm & 0xF > 0) or ((x & 1) == 1 and bm & 0xF0 > 0) else 0
if (y * bitmap.width + x) % 8 == 7:
pixelsbw.append(px)
px = 0
if (bitmap.width * bitmap.rows) % 8 != 0:
px = px << (8 - (bitmap.width * bitmap.rows) % 8)
pixelsbw.append(px)
# for y in range(bitmap.rows):
# line = ''
# for x in range(bitmap.width):
# pixelPosition = y * bitmap.width + x
# byte = pixelsbw[pixelPosition // 8]
# bit_index = 7 - (pixelPosition % 8)
# line += '#' if (byte >> bit_index) & 1 else '.'
# print(line)
# print('')
pixels = pixels2b if is2Bit else pixelsbw
# Build output data
packed = bytes(pixels)
glyph = GlyphProps(
width = bitmap.width,
height = bitmap.rows,
advance_x = norm_floor(face.glyph.advance.x),
left = face.glyph.bitmap_left,
top = face.glyph.bitmap_top,
compressed_size = len(compressed),
data_length = len(packed),
data_offset = total_size,
code_point = code_point,
)
total_size += len(compressed)
all_glyphs.append((glyph, compressed))
total_size += len(packed)
all_glyphs.append((glyph, packed))
# pipe seems to be a good heuristic for the "real" descender
face = load_glyph(ord('|'))
@@ -201,11 +261,11 @@ face = load_glyph(ord('|'))
glyph_data = []
glyph_props = []
for index, glyph in enumerate(all_glyphs):
props, compressed = glyph
glyph_data.extend([b for b in compressed])
props, packed = glyph
glyph_data.extend([b for b in packed])
glyph_props.append(props)
print(f"/**\n * generated by fontconvert.py\n * name: {font_name}\n * size: {size}\n * compressed: {compress}\n */")
print(f"/**\n * generated by fontconvert.py\n * name: {font_name}\n * size: {size}\n * mode: {'2-bit' if is2Bit else '1-bit'}\n */")
print("#pragma once")
print("#include \"EpdFontData.h\"\n")
print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{")
@@ -230,8 +290,8 @@ print(f" {font_name}Bitmaps,")
print(f" {font_name}Glyphs,")
print(f" {font_name}Intervals,")
print(f" {len(intervals)},")
print(f" {1 if compress else 0},")
print(f" {norm_ceil(face.size.height)},")
print(f" {norm_ceil(face.size.ascender)},")
print(f" {norm_floor(face.size.descender)},")
print(f" {'true' if is2Bit else 'false'},")
print("};")
-132
View File
@@ -1,132 +0,0 @@
#pragma once
#include <EpdFont.h>
#include <HardwareSerial.h>
#include <Utf8.h>
#include <miniz.h>
inline int min(const int a, const int b) { return a < b ? a : b; }
inline int max(const int a, const int b) { return a > b ? a : b; }
static tinfl_decompressor decomp;
template <typename Renderable>
class EpdFontRenderer {
Renderable* renderer;
void renderChar(uint32_t cp, int* x, const int* y, uint16_t color);
public:
const EpdFont* font;
explicit EpdFontRenderer(const EpdFont* font, Renderable* renderer);
~EpdFontRenderer() = default;
void renderString(const char* string, int* x, int* y, uint16_t color);
};
inline int uncompress(uint8_t* dest, size_t uncompressedSize, const uint8_t* source, size_t sourceSize) {
if (uncompressedSize == 0 || dest == nullptr || sourceSize == 0 || source == nullptr) {
return -1;
}
tinfl_init(&decomp);
// we know everything will fit into the buffer.
const tinfl_status decomp_status =
tinfl_decompress(&decomp, source, &sourceSize, dest, dest, &uncompressedSize,
TINFL_FLAG_PARSE_ZLIB_HEADER | TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF);
if (decomp_status != TINFL_STATUS_DONE) {
return decomp_status;
}
return 0;
}
template <typename Renderable>
EpdFontRenderer<Renderable>::EpdFontRenderer(const EpdFont* font, Renderable* renderer) {
this->font = font;
this->renderer = renderer;
}
template <typename Renderable>
void EpdFontRenderer<Renderable>::renderString(const char* string, int* x, int* y, const uint16_t color) {
// cannot draw a NULL / empty string
if (string == nullptr || *string == '\0') {
return;
}
// no printable characters
if (!font->hasPrintableChars(string)) {
return;
}
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
renderChar(cp, x, y, color);
}
*y += font->data->advanceY;
}
template <typename Renderable>
void EpdFontRenderer<Renderable>::renderChar(const uint32_t cp, int* x, const int* y, uint16_t color) {
const EpdGlyph* glyph = font->getGlyph(cp);
if (!glyph) {
// TODO: Replace with fallback glyph property?
glyph = font->getGlyph('?');
}
// no glyph?
if (!glyph) {
Serial.printf("No glyph for codepoint %d\n", cp);
return;
}
const uint32_t offset = glyph->dataOffset;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const int byteWidth = width / 2 + width % 2;
const unsigned long bitmapSize = byteWidth * height;
const uint8_t* bitmap = nullptr;
if (font->data->compressed) {
auto* tmpBitmap = static_cast<uint8_t*>(malloc(bitmapSize));
if (tmpBitmap == nullptr && bitmapSize) {
// ESP_LOGE("font", "malloc failed.");
return;
}
uncompress(tmpBitmap, bitmapSize, &font->data->bitmap[offset], glyph->compressedSize);
bitmap = tmpBitmap;
} else {
bitmap = &font->data->bitmap[offset];
}
if (bitmap != nullptr) {
for (int localY = 0; localY < height; localY++) {
int yy = *y - glyph->top + localY;
const int startPos = *x + left;
bool byteComplete = startPos % 2;
int localX = max(0, -startPos);
const int maxX = startPos + width;
for (int xx = startPos; xx < maxX; xx++) {
uint8_t bm = bitmap[localY * byteWidth + localX / 2];
if ((localX & 1) == 0) {
bm = bm & 0xF;
} else {
bm = bm >> 4;
}
if (bm) {
renderer->drawPixel(xx, yy, color);
}
byteComplete = !byteComplete;
localX++;
}
}
if (font->data->compressed) {
free(const_cast<uint8_t*>(bitmap));
}
}
*x += glyph->advanceX;
}

Some files were not shown because too many files have changed in this diff Show More