python ztc viewer

This commit is contained in:
Ronny Dewaele 2026-01-20 08:43:13 +01:00
parent 6d68466891
commit 5eac79b6c9
3 changed files with 425 additions and 141 deletions

154
README.md
View File

@ -1,149 +1,21 @@
# CrossPoint Reader
# Python XTC Viewer
Firmware for the **Xteink X4** e-paper display reader (unaffiliated with Xteink).
Built using **PlatformIO** and targeting the **ESP32-C3** microcontroller.
Desktop viewer for XTC/XTCH e-book files used by CrossPoint Reader on the Xteink X4. Enables you to preview XTC files in an easy way.
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.
## Usage
![](./docs/images/cover.jpg)
## Motivation
E-paper devices are fantastic for reading, but most commercially available readers are closed systems with limited
customisation. The **Xteink X4** is an affordable, e-paper device, however the official firmware remains closed.
CrossPoint exists partly as a fun side-project and partly to open up the ecosystem and truely unlock the device's
potential.
CrossPoint Reader aims to:
* Provide a **fully open-source alternative** to the official firmware.
* Offer a **document reader** capable of handling EPUB content on constrained hardware.
* Support **customisable font, layout, and display** options.
* Run purely on the **Xteink X4 hardware**.
This project is **not affiliated with Xteink**; it's built as a community project.
## Features & Usage
- [x] EPUB parsing and rendering (EPUB 2 and EPUB 3)
- [ ] Image support within EPUB
- [x] Saved reading position
- [x] File explorer with file picker
- [x] Basic EPUB picker from root directory
- [x] Support nested folders
- [ ] EPUB picker with cover art
- [x] Custom sleep screen
- [x] Cover sleep screen
- [x] Wifi book upload
- [x] Wifi OTA updates
- [x] Configurable font, layout, and display options
- [ ] User provided fonts
- [ ] Full UTF support
- [x] Screen rotation
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
## Installing
### Web (latest firmware)
1. Connect your Xteink X4 to your computer via USB-C
2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware"
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.
### Web (specific firmware version)
1. Connect your Xteink X4 to your computer via USB-C
2. Download the `firmware.bin` file from the release of your choice 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
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.
### Manual
See [Development](#development) below.
## Development
### Prerequisites
* **PlatformIO Core** (`pio`) or **VS Code + PlatformIO IDE**
* Python 3.8+
* 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
```bash
python bin/xtc-viewer.py <file.xtc|file.xtch>
```
### Flashing your device
## Controls
Connect your Xteink X4 to your computer via USB-C and run the following command.
| Key/Button | Action |
|------------|--------|
| Left/Right, Space | Navigate pages |
| R | Toggle landscape/portrait |
| 1 | Toggle 1:1 X4 display size (480×800) |
| Escape | Quit |
```sh
pio run --target upload
```
![](./docs/images/screenshot_xtc_viewer.png)
## Internals
CrossPoint Reader is pretty aggressive about caching data down to the SD card to minimise RAM usage. The ESP32-C3 only
has ~380KB of usable RAM, so we have to be careful. A lot of the decisions made in the design of the firmware were based
on this constraint.
### Data caching
The first time chapters of a book are loaded, they are cached to the SD card. Subsequent loads are served from the
cache. This cache directory exists at `.crosspoint` on the SD card. The structure is as follows:
```
.crosspoint/
├── epub_12471232/ # Each EPUB is cached to a subdirectory named `epub_<hash>`
│ ├── progress.bin # Stores reading progress (chapter, page, etc.)
│ ├── cover.bmp # Book cover image (once generated)
│ ├── book.bin # Book metadata (title, author, spine, table of contents, etc.)
│ └── sections/ # All chapter data is stored in the sections subdirectory
│ ├── 0.bin # Chapter data (screen count, all text layout info, etc.)
│ ├── 1.bin # files are named by their index in the spine
│ └── ...
└── epub_189013891/
```
Deleting the `.crosspoint` directory will clear the entire cache.
Due the way it's currently implemented, the cache is not automatically cleared when a book is deleted and moving a book
file will use a new cache directory, resetting the reading progress.
For more details on the internal file structures, see the [file formats document](./docs/file-formats.md).
## Contributing
Contributions are very welcome!
If you're looking for a way to help out, take a look at the [ideas discussion board](https://github.com/daveallie/crosspoint-reader/discussions/categories/ideas).
If there's something there you'd like to work on, leave a comment so that we can avoid duplicated effort.
### To submit a contribution:
1. Fork the repo
2. Create a branch (`feature/dithering-improvement`)
3. Make changes
4. Submit a PR
---
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.

412
bin/xtc-viewer.py Executable file
View File

@ -0,0 +1,412 @@
#!/usr/bin/env python3
"""
XTC/XTCH file viewer for CrossPoint Reader e-book format.
Usage: python xtc-viewer.py <file.xtc|file.xtch>
"""
import logging
import struct
import sys
import tkinter as tk
from tkinter import ttk, messagebox
from pathlib import Path
from enum import Enum
from PIL import Image, ImageTk
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
class BitDepth(Enum):
XTG_1BIT = 1
XTH_2BIT = 2
class XtcFile:
XTC_MAGIC = 0x00435458
XTCH_MAGIC = 0x48435458
XTG_PAGE_MAGIC = 0x00475458
XTH_PAGE_MAGIC = 0x00485458
def __init__(self, filepath: str):
self.filepath = Path(filepath)
self.bit_depth: BitDepth | None = None
self.page_count = 0
self.page_table: list[dict] = []
self.title = ""
self._file_handle = None
def open(self):
logging.info(f"Opening file: {self.filepath}")
logging.info(f"File size: {self.filepath.stat().st_size} bytes")
self._file_handle = open(self.filepath, "rb")
self._read_header()
self._read_page_table()
self._read_title()
logging.info(f"File opened successfully: {self.page_count} pages, bit_depth={self.bit_depth}")
def close(self):
if self._file_handle:
self._file_handle.close()
self._file_handle = None
def _read_header(self):
self._file_handle.seek(0)
data = self._file_handle.read(56)
logging.debug(f"Read {len(data)} bytes for header")
logging.debug(f"First 16 bytes (hex): {data[:16].hex()}")
(
magic,
ver_maj,
ver_min,
page_count,
flags,
header_size,
reserved1,
toc_offset,
page_table_offset,
data_offset,
reserved2,
title_offset,
padding,
) = struct.unpack("<IBBHIIIIQQQII", data)
logging.debug(f"Magic: 0x{magic:08x} (expected XTC=0x{self.XTC_MAGIC:08x}, XTCH=0x{self.XTCH_MAGIC:08x})")
logging.debug(f"Version: {ver_maj}.{ver_min}")
logging.debug(f"Page count: {page_count}")
logging.debug(f"Header size: {header_size}")
logging.debug(f"Page table offset: {page_table_offset}")
logging.debug(f"Data offset: {data_offset}")
logging.debug(f"Title offset: {title_offset}")
if magic == self.XTC_MAGIC:
self.bit_depth = BitDepth.XTG_1BIT
logging.info("Detected XTC (1-bit) format")
elif magic == self.XTCH_MAGIC:
self.bit_depth = BitDepth.XTH_2BIT
logging.info("Detected XTCH (2-bit) format")
else:
logging.error(f"Invalid magic number: 0x{magic:08x}")
raise ValueError(f"Invalid magic number: 0x{magic:08x}")
self.page_count = page_count
self._page_table_offset = page_table_offset
self._title_offset = title_offset
def _read_page_table(self):
logging.debug(f"Reading page table at offset {self._page_table_offset}")
self._file_handle.seek(self._page_table_offset)
self.page_table = []
for i in range(self.page_count):
entry = self._file_handle.read(16)
offset, size, width, height = struct.unpack("<QIHH", entry)
self.page_table.append(
{"index": i, "offset": offset, "size": size, "width": width, "height": height}
)
if i < 3 or i == self.page_count - 1:
logging.debug(f" Page {i}: offset={offset}, size={size}, {width}x{height}")
elif i == 3:
logging.debug(f" ... ({self.page_count - 4} more pages) ...")
def _read_title(self):
if self._title_offset > 0:
logging.debug(f"Reading title at offset {self._title_offset}")
self._file_handle.seek(self._title_offset)
title_bytes = b""
while True:
byte = self._file_handle.read(1)
if not byte or byte == b"\x00":
break
title_bytes += byte
if len(title_bytes) > 128:
break
self.title = title_bytes.decode("utf-8", errors="replace")
logging.info(f"Title: '{self.title}'")
def load_page(self, page_index: int) -> Image.Image:
if page_index < 0 or page_index >= self.page_count:
raise IndexError(f"Page {page_index} out of range (0-{self.page_count - 1})")
page_info = self.page_table[page_index]
logging.debug(f"Loading page {page_index} from offset {page_info['offset']}")
self._file_handle.seek(page_info["offset"])
header = self._file_handle.read(22)
magic, width, height, color_mode, compression, data_size, md5 = struct.unpack(
"<IHHBBIQ", header
)
logging.debug(f"Page header: magic=0x{magic:08x}, {width}x{height}, compression={compression}, data_size={data_size}")
expected_magic = (
self.XTG_PAGE_MAGIC if self.bit_depth == BitDepth.XTG_1BIT else self.XTH_PAGE_MAGIC
)
if magic != expected_magic:
logging.error(f"Invalid page magic: 0x{magic:08x}, expected 0x{expected_magic:08x}")
raise ValueError(f"Invalid page magic: 0x{magic:08x}, expected 0x{expected_magic:08x}")
bitmap = self._file_handle.read(data_size)
logging.debug(f"Read {len(bitmap)} bytes of bitmap data")
if self.bit_depth == BitDepth.XTG_1BIT:
return self._decode_1bit(bitmap, width, height)
else:
return self._decode_2bit(bitmap, width, height)
def _decode_1bit(self, bitmap: bytes, width: int, height: int) -> Image.Image:
"""Decode 1-bit monochrome (row-major, MSB first)."""
row_size = (width + 7) // 8
img = Image.new("L", (width, height))
pixels = img.load()
for y in range(height):
for x in range(width):
byte_idx = y * row_size + x // 8
if byte_idx < len(bitmap):
bit_idx = 7 - (x % 8)
pixel = (bitmap[byte_idx] >> bit_idx) & 1
pixels[x, y] = 255 if pixel else 0
return img
def _decode_2bit(self, bitmap: bytes, width: int, height: int) -> Image.Image:
"""Decode 2-bit grayscale (column-major, right-to-left, two bit planes)."""
plane_size = (width * height + 7) // 8
plane1 = bitmap[:plane_size]
plane2 = bitmap[plane_size : plane_size * 2]
col_bytes = (height + 7) // 8
img = Image.new("L", (width, height))
pixels = img.load()
for y in range(height):
for x in range(width):
col_idx = width - 1 - x
byte_in_col = y // 8
bit_in_byte = 7 - (y % 8)
byte_offset = col_idx * col_bytes + byte_in_col
if byte_offset < len(plane1) and byte_offset < len(plane2):
bit1 = (plane1[byte_offset] >> bit_in_byte) & 1
bit2 = (plane2[byte_offset] >> bit_in_byte) & 1
pixel_value = (bit1 << 1) | bit2
grayscale = (3 - pixel_value) * 85
pixels[x, y] = grayscale
return img
class XtcViewer:
def __init__(self, filepath: str):
self.xtc = XtcFile(filepath)
self.current_page = 0
self.photo_image = None
self.landscape = False
self.native_size = False # 1:1 pixel mapping to X4 display (480x800)
self.root = tk.Tk()
self.root.title(f"XTC Viewer - {Path(filepath).name}")
self.root.configure(bg="#2d2d2d")
self._setup_ui()
self._bind_keys()
def _setup_ui(self):
main_frame = ttk.Frame(self.root, padding=10)
main_frame.pack(fill=tk.BOTH, expand=True)
style = ttk.Style()
style.configure("TButton", padding=6)
style.configure("TLabel", background="#2d2d2d", foreground="white")
self.info_label = ttk.Label(main_frame, text="Loading...", font=("Arial", 10))
self.info_label.pack(pady=(0, 10))
canvas_frame = ttk.Frame(main_frame)
canvas_frame.pack(fill=tk.BOTH, expand=True)
self.canvas = tk.Canvas(canvas_frame, bg="#1a1a1a", highlightthickness=0)
self.canvas.pack(fill=tk.BOTH, expand=True)
button_frame = ttk.Frame(main_frame)
button_frame.pack(pady=(10, 0))
self.prev_button = ttk.Button(button_frame, text="< Previous", command=self.prev_page)
self.prev_button.pack(side=tk.LEFT, padx=5)
self.page_label = ttk.Label(button_frame, text="Page 0 / 0", font=("Arial", 11))
self.page_label.pack(side=tk.LEFT, padx=20)
self.next_button = ttk.Button(button_frame, text="Next >", command=self.next_page)
self.next_button.pack(side=tk.LEFT, padx=5)
self.rotate_button = ttk.Button(button_frame, text="⟳ Landscape", command=self.toggle_orientation)
self.rotate_button.pack(side=tk.LEFT, padx=(20, 5))
self.native_button = ttk.Button(button_frame, text="1:1 X4", command=self.toggle_native_size)
self.native_button.pack(side=tk.LEFT, padx=5)
def _bind_keys(self):
self.root.bind("<Left>", lambda e: self.prev_page())
self.root.bind("<Right>", lambda e: self.next_page())
self.root.bind("<Up>", lambda e: self.prev_page())
self.root.bind("<Down>", lambda e: self.next_page())
self.root.bind("<Prior>", lambda e: self.prev_page())
self.root.bind("<Next>", lambda e: self.next_page())
self.root.bind("<space>", lambda e: self.next_page())
self.root.bind("<Escape>", lambda e: self.root.quit())
self.root.bind("r", lambda e: self.toggle_orientation())
self.root.bind("R", lambda e: self.toggle_orientation())
self.root.bind("1", lambda e: self.toggle_native_size())
self.canvas.bind("<Configure>", lambda e: self._display_page())
def run(self):
try:
logging.info("Starting viewer...")
self.xtc.open()
title = self.xtc.title or Path(self.xtc.filepath).stem
bit_info = "1-bit" if self.xtc.bit_depth == BitDepth.XTG_1BIT else "2-bit"
self.info_label.config(text=f"{title} ({bit_info}, {self.xtc.page_count} pages)")
logging.info(f"Updated info label: {title} ({bit_info}, {self.xtc.page_count} pages)")
if self.xtc.page_count > 0:
page_info = self.xtc.page_table[0]
window_width = min(page_info["width"] + 40, 800)
window_height = min(page_info["height"] + 120, 950)
self.root.geometry(f"{window_width}x{window_height}")
logging.info(f"Set window geometry to {window_width}x{window_height}")
self._display_page()
self._update_buttons()
logging.info("Entering main loop")
self.root.mainloop()
except Exception as e:
logging.exception(f"Error in viewer: {e}")
messagebox.showerror("Error", str(e))
raise
finally:
self.xtc.close()
def _display_page(self):
if self.xtc.page_count == 0:
logging.warning("No pages to display")
return
try:
logging.debug(f"Displaying page {self.current_page}")
img = self.xtc.load_page(self.current_page)
logging.debug(f"Loaded image: {img.width}x{img.height}")
if self.landscape:
img = img.rotate(90, expand=True)
logging.debug(f"Rotated to landscape: {img.width}x{img.height}")
canvas_width = self.canvas.winfo_width()
canvas_height = self.canvas.winfo_height()
logging.debug(f"Canvas size: {canvas_width}x{canvas_height}")
if self.native_size:
# 1:1 pixel mapping - resize window to fit native image size
logging.debug(f"Native size mode: {img.width}x{img.height}")
elif canvas_width > 1 and canvas_height > 1:
scale_x = canvas_width / img.width
scale_y = canvas_height / img.height
scale = min(scale_x, scale_y, 1.0)
if scale < 1.0:
new_width = int(img.width * scale)
new_height = int(img.height * scale)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
logging.debug(f"Scaled image to {new_width}x{new_height}")
self.photo_image = ImageTk.PhotoImage(img)
self.canvas.delete("all")
x = max(0, (canvas_width - img.width) // 2)
y = max(0, (canvas_height - img.height) // 2)
self.canvas.create_image(x, y, anchor=tk.NW, image=self.photo_image)
logging.debug(f"Image placed at ({x}, {y})")
except Exception as e:
logging.exception(f"Error displaying page: {e}")
self.canvas.delete("all")
self.canvas.create_text(
self.canvas.winfo_width() // 2,
self.canvas.winfo_height() // 2,
text=f"Error loading page: {e}",
fill="red",
)
def _update_buttons(self):
self.page_label.config(text=f"Page {self.current_page + 1} / {self.xtc.page_count}")
self.prev_button.config(state=tk.NORMAL if self.current_page > 0 else tk.DISABLED)
self.next_button.config(
state=tk.NORMAL if self.current_page < self.xtc.page_count - 1 else tk.DISABLED
)
def prev_page(self):
if self.current_page > 0:
self.current_page -= 1
self._display_page()
self._update_buttons()
def next_page(self):
if self.current_page < self.xtc.page_count - 1:
self.current_page += 1
self._display_page()
self._update_buttons()
def toggle_orientation(self):
self.landscape = not self.landscape
if self.landscape:
self.rotate_button.config(text="⟳ Portrait")
logging.info("Switched to landscape mode")
else:
self.rotate_button.config(text="⟳ Landscape")
logging.info("Switched to portrait mode")
# Resize window if in native size mode
if self.native_size:
if self.landscape:
self.root.geometry(f"{800 + 40}x{480 + 120}")
else:
self.root.geometry(f"{480 + 40}x{800 + 120}")
self._display_page()
def toggle_native_size(self):
self.native_size = not self.native_size
if self.native_size:
self.native_button.config(text="Fit")
# Resize window to native X4 display size + UI chrome
if self.landscape:
self.root.geometry(f"{800 + 40}x{480 + 120}")
else:
self.root.geometry(f"{480 + 40}x{800 + 120}")
logging.info("Switched to 1:1 native X4 size (480x800)")
else:
self.native_button.config(text="1:1 X4")
logging.info("Switched to fit-to-window mode")
self._display_page()
def main():
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <file.xtc|file.xtch>")
sys.exit(1)
filepath = sys.argv[1]
if not Path(filepath).exists():
print(f"Error: File not found: {filepath}")
sys.exit(1)
viewer = XtcViewer(filepath)
viewer.run()
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB