diff --git a/README.md b/README.md index d59df835..f59eecce 100644 --- a/README.md +++ b/README.md @@ -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 ``` -### 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_` -│ ├── 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. diff --git a/bin/xtc-viewer.py b/bin/xtc-viewer.py new file mode 100755 index 00000000..dd1fdaf5 --- /dev/null +++ b/bin/xtc-viewer.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +""" +XTC/XTCH file viewer for CrossPoint Reader e-book format. + +Usage: python xtc-viewer.py +""" + +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(" 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( + " 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("", lambda e: self.prev_page()) + self.root.bind("", lambda e: self.next_page()) + self.root.bind("", lambda e: self.prev_page()) + self.root.bind("", lambda e: self.next_page()) + self.root.bind("", lambda e: self.prev_page()) + self.root.bind("", lambda e: self.next_page()) + self.root.bind("", lambda e: self.next_page()) + self.root.bind("", 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("", 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]} ") + 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() diff --git a/docs/images/screenshot_xtc_viewer.png b/docs/images/screenshot_xtc_viewer.png new file mode 100644 index 00000000..e0d59403 Binary files /dev/null and b/docs/images/screenshot_xtc_viewer.png differ