mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 15:17:37 +03:00
Merge 5eac79b6c9 into 838993259d
This commit is contained in:
commit
53127047ee
154
README.md
154
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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||

|
||||
|
||||
## 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
412
bin/xtc-viewer.py
Executable 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()
|
||||
BIN
docs/images/screenshot_xtc_viewer.png
Normal file
BIN
docs/images/screenshot_xtc_viewer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Loading…
Reference in New Issue
Block a user