From 1daf340b8df4a04f0b309b1472749ab33f900140 Mon Sep 17 00:00:00 2001 From: casualducko Date: Sat, 6 Jun 2026 15:31:01 -0400 Subject: [PATCH 1/6] Add Xteink X3 support with device toggle X3 profile: 528x792 display, 2-level B/W Floyd-Steinberg dithering (stock X3 firmware only renders 1-bit; gray data would be discarded). Also fixes the image fit box orientation for both devices: panels scan landscape but the readers display portrait, so images now fit 480x800 (X4) / 528x792 (X3), matching the CrossPoint reference converter. Portrait illustrations previously got crushed to 480px tall and upscaled blurrily by the reader; light novel pages now fill the full screen. Generated covers are quantized to the device palette so gray borders/text survive low-bit-depth displays. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + README.md | 43 +++++++++------- app.py | 9 +++- epub_processor.py | 20 +++++--- image_processor.py | 118 +++++++++++++++++++++++++++++++------------ static/app.js | 19 +++++++ static/style.css | 68 +++++++++++++++++++++++++ templates/index.html | 26 +++++++--- 8 files changed, 239 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index 6883510..e248e11 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ .env .venv/ venv/ +*.epub diff --git a/README.md b/README.md index dfac285..cedc5fd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A web-based EPUB optimizer for e-ink readers. Drop in any EPUB and get back a clean, optimized file ready for your device. -Originally built for the [Xteink X4](https://xteink.com/) (800x480 e-ink display, 4-level grayscale, SSD1677 controller, ESP32-C3) but works with any Xteink reader or e-ink device that supports EPUB. +Originally built for the [Xteink X4](https://xteink.com/) (480x800 portrait e-ink display, 4-level grayscale, SSD1677 controller, ESP32-C3). Now also supports the smaller [Xteink X3](https://www.xteink.com/products/xteink-x3) (528x792 display, 1-bit black/white on stock firmware) via a device toggle, and works with any Xteink reader or e-ink device that supports EPUB. ## Processing pipeline @@ -16,7 +16,7 @@ epubkit runs a 20-step pipeline on every EPUB: | 4 | **Read metadata** — extracts title, author, series, language, cover reference | | 5 | **Apply metadata edits** — overwrites title/author if the user edited them in the UI | | 6 | **Find content files** — catalogs all XHTML, CSS, image, and font files in the EPUB | -| 7 | **Process images** — converts all images to baseline JPEG, resizes to 800x480 (max 1024x1024), applies 4-level grayscale quantization with Floyd-Steinberg dithering, autocontrast histogram stretching, and contrast boost. Light Novel mode rotates/splits landscape images | +| 7 | **Process images** — converts all images to baseline JPEG, resizes to the device screen (480x800 for X4, 528x792 for X3; max 1024x1024), applies grayscale quantization with Floyd-Steinberg dithering (4-level on X4, 2-level black/white on X3), autocontrast histogram stretching, and contrast boost. Light Novel mode rotates/splits landscape images | | 8 | **Fix SVG covers** — unwraps SVG-wrapped cover images (common in Gutenberg/store EPUBs) | | 9 | **Generate cover** — creates a title/author cover image if the book doesn't have one | | 10 | **Update references** — rewrites all internal hrefs and srcs to match renamed image files | @@ -35,37 +35,44 @@ epubkit runs a 20-step pipeline on every EPUB: 1. **Drop** one or more EPUB files onto the upload zone 2. **Edit** title/author if needed (auto-detected from metadata) -3. **Pick a preset**: Quick (images + text), Full (X4-optimized), or Custom -4. **Click Optimize** and watch real-time progress via SSE streaming -5. **Download** the optimized EPUB — ready to transfer to your reader +3. **Pick your device**: X4 or X3 (sets screen size and grayscale depth) +4. **Pick a preset**: Quick (images + text), Full (device-optimized), or Custom +5. **Click Optimize** and watch real-time progress via SSE streaming +6. **Download** the optimized EPUB — ready to transfer to your reader ## Processing presets | Preset | Images | Text | Fonts | CSS | Cover | Metadata | Best for | |--------|--------|------|-------|-----|-------|----------|----------| | Quick | Yes | Yes | No | No | No | No | Fast image + text pass | -| Full | Yes | Yes | Yes | Yes | Yes | Yes | Complete X4 optimization | +| Full | Yes | Yes | Yes | Yes | Yes | Yes | Complete device optimization | | Custom | Pick | Pick | Pick | Pick| Pick | Pick | Fine-grained control | -## Xteink X4 specs +The device toggle (X4/X3) works independently of the preset — it controls image dimensions and grayscale depth. + +## Device specs The optimizer is tuned for these hardware constraints: -| Spec | Value | -|------|-------| -| Display | 800x480 e-ink panel | -| Grayscale | 4 levels (SSD1677 controller): black, dark gray, light gray, white | -| Processor | ESP32-C3, 160MHz | -| RAM | 380KB usable | -| Max image | 1024x1024 pixels | -| Formats | EPUB, XTC, XTCH, Markdown, TXT | -| Storage | 32GB + microSD | +| Spec | X4 | X3 | +|------|----|----| +| Display | 480x800 portrait (4.3" panel) | 528x792 portrait (3.7" panel) | +| Grayscale | 4 levels (SSD1677): black, dark gray, light gray, white | 1-bit black/white (stock firmware reads only the first bit plane) | +| Processor | ESP32-C3, 160MHz | ESP32-C3 | +| RAM | 380KB usable | 380KB usable | +| Max image | 1024x1024 pixels | 1024x1024 pixels | +| Formats | EPUB, XTC, XTCH, Markdown, TXT | EPUB, TXT | +| Storage | 32GB + microSD | 16GB microSD | + +Image fit boxes match the [CrossPoint reference converter](https://github.com/crosspoint-reader/crosspoint-reader) device profiles (X4: 480x800, X3: 528x792). The panels scan in landscape, but the readers display portrait — images sized to the portrait box render sharp without reader-side upscaling. + +Note: the X3's SSD1677 controller is hardware-capable of 4-level grayscale, but stock firmware only renders black/white — so the X3 profile dithers to 2 levels, which looks better than letting the device discard gray data. (Custom firmware like [CrossPoint Reader](https://github.com/crosspoint-reader/crosspoint-reader) adds grayscale support; if you run it, the X4 profile's 4-level output also works on an X3.) ## Image processing details - **Format**: All images converted to baseline JPEG (progressive breaks many e-ink readers) -- **Resize**: Fit within 800x480 screen, hard clamp at 1024x1024 -- **Grayscale**: 4-level quantization matching SSD1677 palette (0, 85, 170, 255) with Floyd-Steinberg dithering +- **Resize**: Fit within the device screen (480x800 X4, 528x792 X3, portrait), hard clamp at 1024x1024 +- **Grayscale**: X4 — 4-level quantization matching SSD1677 palette (0, 85, 170, 255); X3 — 2-level black/white (0, 255); both with Floyd-Steinberg dithering - **Contrast**: Auto-histogram stretching (`ImageOps.autocontrast`) followed by 1.5x contrast boost - **Subsampling**: 4:2:0 for grayscale (all RGB channels identical, saves ~15-20%), 4:4:4 for color - **Transparency**: Alpha composited onto white background diff --git a/app.py b/app.py index f6f2a11..814d035 100644 --- a/app.py +++ b/app.py @@ -133,6 +133,7 @@ async def upload_files(files: list[UploadFile] = File(...)): @app.get("/process/{task_id}") async def process_sse( task_id: str, + device: str = "x4", grayscale: bool = True, contrast: bool = True, quality: int = 70, @@ -153,12 +154,16 @@ async def process_sse( if task["status"] == "processing": raise HTTPException(status_code=409, detail="Already processing") + if device not in ("x4", "x3"): + raise HTTPException(status_code=400, detail="Unknown device (expected 'x4' or 'x3')") + input_path = task["file_path"] out_dir = OUTPUT_DIR / task_id out_dir.mkdir(parents=True, exist_ok=True) output_path = str(out_dir / "output.epub") options = ProcessingOptions( + device=device, grayscale=grayscale, contrast_boost=contrast, quality=quality, @@ -291,7 +296,7 @@ async def download_all(task_ids: str): # Create ZIP file zip_dir = OUTPUT_DIR / "batch" zip_dir.mkdir(parents=True, exist_ok=True) - zip_path = str(zip_dir / f"x4_optimized_{uuid.uuid4().hex[:8]}.zip") + zip_path = str(zip_dir / f"epubkit_optimized_{uuid.uuid4().hex[:8]}.zip") with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: for task_id in ids: @@ -305,7 +310,7 @@ async def download_all(task_ids: str): return FileResponse( zip_path, media_type="application/zip", - filename="x4_optimized_epubs.zip", + filename="epubkit_optimized_epubs.zip", ) diff --git a/epub_processor.py b/epub_processor.py index deef42c..218b317 100644 --- a/epub_processor.py +++ b/epub_processor.py @@ -1,5 +1,5 @@ """ -Main EPUB processing pipeline for Xteink X4 Optimizer. +Main EPUB processing pipeline for Xteink Optimizer (X4, X3). Orchestrates all processing steps and generates validation reports. """ @@ -13,7 +13,8 @@ from lxml import etree from image_processor import ( - ImageOptions, process_image, should_process, generate_cover_image + ImageOptions, process_image, should_process, generate_cover_image, + DEVICE_PROFILES, DEFAULT_DEVICE ) from metadata_handler import ( extract_metadata, update_metadata, strip_store_metadata, format_filename @@ -37,11 +38,12 @@ @dataclass class ProcessingOptions: """All user-configurable processing options.""" + device: str = DEFAULT_DEVICE # 'x4' (480x800, 4-level) or 'x3' (528x792, B/W) grayscale: bool = True contrast_boost: bool = True - contrast_factor: float = 1.5 # Higher default for 4-level display + contrast_factor: float = 1.5 # Higher default for low-bit-depth displays quality: int = 70 - eink_quantize: bool = True # 4-level grayscale for SSD1677 + eink_quantize: bool = True # Quantize to device gray levels remove_fonts: bool = True remove_unused_css: bool = True light_novel_mode: bool = False @@ -182,7 +184,8 @@ def _progress(pct: int, msg: str): # Step 7: Process images (20-60%) _progress(15, "Processing images...") - image_options = ImageOptions( + image_options = ImageOptions.for_device( + options.device, grayscale=options.grayscale, contrast_boost=options.contrast_boost, contrast_factor=options.contrast_factor, @@ -247,7 +250,12 @@ def _progress(pct: int, msg: str): if not meta['cover_href']: title = options.metadata_edits.get('title', meta['title']) or 'Untitled' author = options.metadata_edits.get('author', meta['author']) or '' - cover_bytes = generate_cover_image(title, author) + profile = DEVICE_PROFILES.get(options.device, DEVICE_PROFILES[DEFAULT_DEVICE]) + cover_bytes = generate_cover_image( + title, author, + width=profile['width'], height=profile['height'], + gray_levels=profile['gray_levels'] if options.eink_quantize else None, + ) opf_dir = str(Path(opf_path).parent) # Determine images directory images_dir = os.path.join(opf_dir, 'images') diff --git a/image_processor.py b/image_processor.py index 2d126ee..2e06b80 100644 --- a/image_processor.py +++ b/image_processor.py @@ -1,30 +1,57 @@ """ -Image processor for Xteink X4 EPUB Optimizer. -Handles: baseline JPEG conversion, resize, 4-level grayscale quantization, +Image processor for Xteink EPUB Optimizer. +Handles: baseline JPEG conversion, resize, grayscale quantization, contrast boost, Light Novel mode. -X4 specs (SSD1677 controller): - - Display: 800x480, 4-level grayscale (black, dark gray, light gray, white) - - Max image: 1024x1024 - - RAM: 380KB — smaller images = faster rendering +Device profiles (display orientation, portrait): + X4 (SSD1677 controller): + - Display: 480x800, 4-level grayscale (black, dark gray, light gray, white) + X3 (SSD1677 controller, stock firmware): + - Display: 528x792, 1-bit black/white (firmware only reads the first + bit plane, so gray data is discarded — dither to 2 levels instead) + Both: + - Max image: 1024x1024 + - RAM: 380KB — smaller images = faster rendering """ import io from pathlib import Path -from dataclasses import dataclass +from dataclasses import dataclass, field from PIL import Image, ImageEnhance, ImageOps, ImageDraw, ImageFont -# X4 screen dimensions (800x480 landscape panel) -X4_WIDTH = 800 -X4_HEIGHT = 480 - -# Hard limit per X4 JPEG spec +# Device screen profiles in display orientation (portrait), matching the +# CrossPoint reference converter (X4: 480x800, X3: 528x792). The panels scan +# in landscape (e.g. 800x480) but the readers display portrait, so images +# must fit the portrait box or the reader upscales them blurrily. +DEVICE_PROFILES = { + 'x4': { + 'width': 480, + 'height': 800, + # SSD1677 4-level grayscale: black, dark gray, light gray, white + 'gray_levels': [0, 85, 170, 255], + 'label': 'Xteink X4', + }, + 'x3': { + 'width': 528, + 'height': 792, + # Stock X3 firmware renders 1-bit black/white only + 'gray_levels': [0, 255], + 'label': 'Xteink X3', + }, +} +DEFAULT_DEVICE = 'x4' + +# X4 display dimensions (portrait), used as defaults +X4_WIDTH = DEVICE_PROFILES['x4']['width'] +X4_HEIGHT = DEVICE_PROFILES['x4']['height'] + +# Hard limit per Xteink JPEG spec MAX_IMAGE_DIMENSION = 1024 -# SSD1677 supports 4-level grayscale: black, dark gray, light gray, white -EINK_PALETTE_LEVELS = [0, 85, 170, 255] +# Default palette (X4) +EINK_PALETTE_LEVELS = DEVICE_PROFILES['x4']['gray_levels'] SUPPORTED_EXTENSIONS = {'.png', '.gif', '.webp', '.bmp', '.jpeg', '.jpg', '.tif', '.tiff'} @@ -33,14 +60,27 @@ class ImageOptions: grayscale: bool = True contrast_boost: bool = True - contrast_factor: float = 1.5 # Higher default for 4-level display + contrast_factor: float = 1.5 # Higher default for low-bit-depth displays quality: int = 70 max_width: int = X4_WIDTH max_height: int = X4_HEIGHT - eink_quantize: bool = True # Quantize to 4 gray levels (SSD1677) + eink_quantize: bool = True # Quantize to device gray levels + gray_levels: list = field(default_factory=lambda: list(EINK_PALETTE_LEVELS)) light_novel_mode: bool = False light_novel_rotate_left: bool = True + @classmethod + def for_device(cls, device: str, **overrides) -> 'ImageOptions': + """Build options from a device profile ('x4' or 'x3').""" + profile = DEVICE_PROFILES.get(device, DEVICE_PROFILES[DEFAULT_DEVICE]) + defaults = { + 'max_width': profile['width'], + 'max_height': profile['height'], + 'gray_levels': list(profile['gray_levels']), + } + defaults.update(overrides) + return cls(**defaults) + @dataclass class ImageResult: @@ -68,24 +108,25 @@ def is_progressive_jpeg(image_bytes: bytes) -> bool: return False -def _quantize_to_4_levels(img: Image.Image) -> Image.Image: +def _quantize_to_levels(img: Image.Image, levels: list[int]) -> Image.Image: """ - Quantize grayscale image to 4 e-ink levels with Floyd-Steinberg dithering. - Maps to: black (0), dark gray (85), light gray (170), white (255). - Uses PIL's built-in quantize with a custom 4-color palette for speed. + Quantize grayscale image to the device's e-ink levels with + Floyd-Steinberg dithering (e.g. [0, 85, 170, 255] for X4, + [0, 255] for X3 black/white). + Uses PIL's built-in quantize with a custom palette for speed. """ - # Build a 4-color grayscale palette image + # Build a grayscale palette image from the device levels palette_img = Image.new('P', (1, 1)) palette = [] - for level in EINK_PALETTE_LEVELS: + for level in levels: palette.extend([level, level, level]) # Pad palette to 256 entries (required by PIL) - palette.extend([0, 0, 0] * (256 - len(EINK_PALETTE_LEVELS))) + palette.extend([0, 0, 0] * (256 - len(levels))) palette_img.putpalette(palette) # Quantize with Floyd-Steinberg dithering rgb = img.convert('RGB') - quantized = rgb.quantize(colors=len(EINK_PALETTE_LEVELS), + quantized = rgb.quantize(colors=len(levels), palette=palette_img, dither=Image.Dither.FLOYDSTEINBERG) return quantized.convert('L') @@ -135,7 +176,7 @@ def _handle_light_novel(img: Image.Image, rotate_left: bool) -> list[Image.Image def process_image(image_bytes: bytes, filename: str, options: ImageOptions = None) -> list[ImageResult]: """ - Process a single image for X4 optimization. + Process a single image for e-ink device optimization. Returns a list of ImageResult (usually 1, but Light Novel mode may split into 2). """ if options is None: @@ -192,7 +233,7 @@ def process_image(image_bytes: bytes, filename: str, options: ImageOptions = Non orig_w, orig_h = current_img.size - # Enforce 1024x1024 hard limit (X4 JPEG spec) + # Enforce 1024x1024 hard limit (Xteink JPEG spec) if orig_w > MAX_IMAGE_DIMENSION or orig_h > MAX_IMAGE_DIMENSION: current_img.thumbnail((MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION), Image.Resampling.LANCZOS) @@ -200,7 +241,7 @@ def process_image(image_bytes: bytes, filename: str, options: ImageOptions = Non details_parts.append(f"clamped {orig_w}x{orig_h}→{clamped_w}x{clamped_h}") orig_w, orig_h = clamped_w, clamped_h - # Resize to fit X4 screen + # Resize to fit device screen if orig_w > options.max_width or orig_h > options.max_height: current_img.thumbnail((options.max_width, options.max_height), Image.Resampling.LANCZOS) @@ -214,15 +255,18 @@ def process_image(image_bytes: bytes, filename: str, options: ImageOptions = Non # Contrast enhancement (before quantization for best results) if options.contrast_boost: if options.eink_quantize: - # Auto-stretch histogram first for better 4-level mapping + # Auto-stretch histogram first for better level mapping current_img = ImageOps.autocontrast(current_img, cutoff=1) enhancer = ImageEnhance.Contrast(current_img) current_img = enhancer.enhance(options.contrast_factor) - # Quantize to 4 e-ink levels with dithering + # Quantize to device e-ink levels with dithering if options.eink_quantize: - current_img = _quantize_to_4_levels(current_img) - details_parts.append("4-level grayscale") + current_img = _quantize_to_levels(current_img, options.gray_levels) + if len(options.gray_levels) == 2: + details_parts.append("B/W dithered") + else: + details_parts.append(f"{len(options.gray_levels)}-level grayscale") else: details_parts.append("grayscale") @@ -272,8 +316,13 @@ def process_image(image_bytes: bytes, filename: str, options: ImageOptions = Non def generate_cover_image(title: str, author: str, - width: int = X4_WIDTH, height: int = X4_HEIGHT) -> bytes: - """Generate a simple cover image from title and author text.""" + width: int = X4_WIDTH, height: int = X4_HEIGHT, + gray_levels: list = None) -> bytes: + """ + Generate a simple cover image from title and author text. + If gray_levels is given, quantize to the device palette (with dithering) + so gray borders/text survive low-bit-depth displays. + """ img = Image.new('RGB', (width, height), (255, 255, 255)) draw = ImageDraw.Draw(img) @@ -337,6 +386,9 @@ def wrap_text(text, font, max_w): draw.text((x, author_y), line, fill=(100, 100, 100), font=author_font) author_y += bbox[3] - bbox[1] + 6 + if gray_levels: + img = _quantize_to_levels(img.convert('L'), gray_levels).convert('RGB') + buffer = io.BytesIO() img.save(buffer, format='JPEG', quality=85, progressive=False, optimize=True) return buffer.getvalue() diff --git a/static/app.js b/static/app.js index 94e84a8..27cc891 100644 --- a/static/app.js +++ b/static/app.js @@ -12,6 +12,7 @@ const qualityValue = document.getElementById('quality-value'); const downloadAllBtn = document.getElementById('download-all-btn'); let uploadedFiles = []; // {task_id, filename, metadata, file_size} +let selectedDevice = 'x4'; // 'x4' (480x800, 4-level gray) or 'x3' (528x792, B/W) // ==================== Upload ==================== @@ -146,6 +147,22 @@ function removeFile(taskId, btn) { // ==================== Options ==================== +// Device toggle +document.querySelectorAll('.device-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.device-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + selectedDevice = btn.dataset.device; + + const hint = document.getElementById('grayscale-hint'); + if (hint) { + hint.textContent = selectedDevice === 'x3' + ? 'Dither images to black & white' + : 'Convert images for e-ink display'; + } + }); +}); + // Preset profiles document.querySelectorAll('.preset-btn').forEach(btn => { btn.addEventListener('click', () => { @@ -271,6 +288,7 @@ async function startProcessing() { function getOptions() { return { + device: selectedDevice, grayscale: document.getElementById('opt-grayscale').checked, contrast: document.getElementById('opt-contrast').checked, quality: parseInt(qualitySlider.value), @@ -286,6 +304,7 @@ function getOptions() { function processFile(taskId, options, editTitle, editAuthor) { return new Promise((resolve, reject) => { const params = new URLSearchParams({ + device: options.device, grayscale: options.grayscale, contrast: options.contrast, quality: options.quality, diff --git a/static/style.css b/static/style.css index 4997b57..43fca8f 100644 --- a/static/style.css +++ b/static/style.css @@ -341,6 +341,64 @@ h2 { box-shadow: var(--shadow-sm); } +.device-bar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.device-label { + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.device-toggle { + display: flex; + flex: 1; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.device-btn { + flex: 1; + padding: 8px 12px; + border: none; + background: var(--surface); + cursor: pointer; + text-align: center; + transition: all var(--transition); +} + +.device-btn + .device-btn { + border-left: 1px solid var(--border); +} + +.device-btn:hover { + background: var(--bg); +} + +.device-btn.active { + background: var(--accent); + color: #fff; +} + +.device-name { + display: block; + font-size: 14px; + font-weight: 600; + line-height: 1.3; +} + +.device-desc { + display: block; + font-size: 11px; + opacity: 0.65; +} + .preset-bar { display: flex; gap: 8px; @@ -777,6 +835,16 @@ footer a:hover { display: none; } + .device-bar { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + + .device-desc { + display: none; + } + footer p { flex-direction: column; gap: 4px; diff --git a/templates/index.html b/templates/index.html index f815d31..5421129 100644 --- a/templates/index.html +++ b/templates/index.html @@ -18,7 +18,7 @@ - + @@ -33,13 +33,13 @@

EPUB Kit

-

Drop in any EPUB and get back a clean, optimized file tuned for the X4's 800×480 4-level grayscale display. A 20-step pipeline handles everything automatically.

+

Drop in any EPUB and get back a clean, optimized file tuned for your Xteink reader — the X4's 480×800 4-level grayscale display or the X3's 528×792 black & white display. A 20-step pipeline handles everything automatically.

🖼
Images - 4-level grayscale with dithering, resized to screen, baseline JPEG + Grayscale or B/W dithering per device, resized to screen, baseline JPEG
@@ -95,6 +95,20 @@

EPUB Kit

Options

+
+ Device +
+ + +
+
+
- + From 89a118eda24092be62c72e8c190be177ee658825 Mon Sep 17 00:00:00 2001 From: casualducko Date: Sat, 6 Jun 2026 15:31:10 -0400 Subject: [PATCH 2/6] X3: use 4-level grayscale, document stock firmware image limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardware testing on stock X3 firmware showed it does not render EPUB images at all (verified with an unmodified store EPUB and 8 encoding variants: odd/even width, Huffman tables, subsampling, single-channel JPEG, small dims, PNG, BMP — all blank). The 2-level B/W dithering existed to protect stock users from gray-data loss, but stock users never see EPUB images; the only X3 users who do run CrossPoint-family firmware, which renders 4-level grayscale. Switch the X3 profile to the same 4-level palette as the X4 (dims remain 528x792) and note the stock limitation in the README. Co-Authored-By: Claude Opus 4.8 (1M context) --- PR_DRAFT.md | 66 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 10 +++---- epub_processor.py | 2 +- image_processor.py | 13 +++++---- static/app.js | 9 +----- templates/index.html | 12 ++++---- 6 files changed, 87 insertions(+), 25 deletions(-) create mode 100644 PR_DRAFT.md diff --git a/PR_DRAFT.md b/PR_DRAFT.md new file mode 100644 index 0000000..37c9475 --- /dev/null +++ b/PR_DRAFT.md @@ -0,0 +1,66 @@ +# Add Xteink X3 support + fix image fit-box orientation + +## Summary + +Two related changes, both validated on real hardware (X4 + X3): + +1. **X3 support** (additive) — a device toggle in the UI with an X3 profile: 528×792 screen, same 4-level SSD1677 grayscale treatment as the X4. +2. **Portrait fit-box fix** (changes existing X4 output) — images now fit the display orientation (X4: 480×800, X3: 528×792) instead of the panel scan orientation (800×480). On-device, this is the difference between a cover that fills the screen and one that renders as a small box in the corner. + +## 1. X3 support + +The X3's display is **528×792 portrait** (3.68", ~259 PPI — confirmed by the PPI math against Xteink's official spec; some review sites incorrectly report it as sharing the X4's 480×800). Same SSD1677 controller and ESP32-C3 as the X4, so the existing 4-level grayscale pipeline applies unchanged — the profile only differs in dimensions. + +Implementation: + +- `image_processor.py`: `DEVICE_PROFILES` dict, `gray_levels` on `ImageOptions`, `_quantize_to_levels()` generalizes the existing 4-level quantizer to any palette (machinery for future devices with different gray depths) +- `epub_processor.py`: `ProcessingOptions.device`; generated covers use device dimensions and are quantized to the device palette so they match the display gamut +- `app.py`: `device=x4|x3` query param on `/process/{task_id}` (validated, defaults to x4) +- UI: X4/X3 segmented toggle above the presets; device choice is independent of the Quick/Full/Custom preset + +**A finding worth documenting** (now in the README): stock X3 firmware does not render images inside EPUBs *at all*. Hardware-tested with an unmodified store EPUB and a diagnostic EPUB covering 8 encoding variants (odd/even widths, optimized/standard Huffman tables, 4:2:0/4:4:4 subsampling, single-channel JPEG, small dimensions, PNG, BMP) — every image page renders blank on stock, including the publisher's original JPEG. X3 image output therefore targets CrossPoint-family firmware, which renders 4-level grayscale on the X3 panel. + +## 2. Portrait fit-box fix + +The existing code fit images within **800×480** (panel scan orientation). But the readers display portrait — CrossPoint's converter profiles are: + +```js +const DEVICE_PROFILES = { + X4: { width: 480, height: 800, label: 'X4' }, + X3: { width: 528, height: 792, label: 'X3' }, +``` + +and the firmware's `XtcTypes.h` defines `DISPLAY_WIDTH = 480; DISPLAY_HEIGHT = 800` (its debug tooling rotates the raw landscape framebuffer 270° for screenshots). + +With the old 800×480 box, a portrait cover was capped at 480px tall (e.g. 320×480), and the firmware renders images at **native size without upscaling** — so covers drew as a small box with the rest of the screen blank. With the portrait box the same cover comes out 480×721 and fills the display. + +Photos from an X4 (same book, same cover page): + +| Old 800×480 box | New 480×800 box | +|---|---| +| *(photo: cover renders ~40% of screen)* | *(photo: cover fills screen)* | + +This also fixes Light Novel mode: rotated landscape pages now fill the full 480×800 instead of being squeezed to 480px tall. + +## Hardware validation + +| Test | Device / firmware | Result | +|---|---|---| +| Portrait vs landscape fit box A/B | X4, CrossInk (CrossPoint fork) | Portrait fills screen; landscape renders small (photos above) | +| X3-processed EPUB | X3, CrossInk | Images render with correct dimensions and tone | +| X3-processed EPUB | X3, **stock** | Image pages blank — stock does not render EPUB images (see finding above; text renders fine) | +| Original unprocessed EPUB (control) | X3, stock | Image pages equally blank — confirms stock limitation, not a pipeline regression | +| 8-variant encoding diagnostic | X3, stock | All blank — rules out odd-width/Huffman/subsampling/format/size causes | + +Pipeline-level verification (2.5MB store EPUB, 13 images): both profiles process in ~0.5s; all images baseline JPEG within the fit box; mimetype first ZIP entry stored uncompressed; all 30 XHTML files well-formed with identical word counts before/after; OPF/NCX parse cleanly. + +## Incidental changes + +- Batch ZIP renamed `x4_optimized_*` → `epubkit_optimized_*` (no longer X4-only) +- `*.epub` added to `.gitignore` (keeps local test books out of the repo) +- Cache-busters bumped on `style.css` / `app.js` + +## Compatibility notes + +- `ProcessingOptions` / `ImageOptions` field defaults are unchanged for existing callers; `device='x4'` is the default everywhere. +- The fit-box change affects existing X4 users' output (intentionally — see photos). Anyone who preferred the old behavior can pass custom `max_width`/`max_height` via `ImageOptions`. diff --git a/README.md b/README.md index cedc5fd..3d03a2f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A web-based EPUB optimizer for e-ink readers. Drop in any EPUB and get back a clean, optimized file ready for your device. -Originally built for the [Xteink X4](https://xteink.com/) (480x800 portrait e-ink display, 4-level grayscale, SSD1677 controller, ESP32-C3). Now also supports the smaller [Xteink X3](https://www.xteink.com/products/xteink-x3) (528x792 display, 1-bit black/white on stock firmware) via a device toggle, and works with any Xteink reader or e-ink device that supports EPUB. +Originally built for the [Xteink X4](https://xteink.com/) (480x800 portrait e-ink display, 4-level grayscale, SSD1677 controller, ESP32-C3). Now also supports the smaller [Xteink X3](https://www.xteink.com/products/xteink-x3) (528x792 display, same controller) via a device toggle, and works with any Xteink reader or e-ink device that supports EPUB. ## Processing pipeline @@ -16,7 +16,7 @@ epubkit runs a 20-step pipeline on every EPUB: | 4 | **Read metadata** — extracts title, author, series, language, cover reference | | 5 | **Apply metadata edits** — overwrites title/author if the user edited them in the UI | | 6 | **Find content files** — catalogs all XHTML, CSS, image, and font files in the EPUB | -| 7 | **Process images** — converts all images to baseline JPEG, resizes to the device screen (480x800 for X4, 528x792 for X3; max 1024x1024), applies grayscale quantization with Floyd-Steinberg dithering (4-level on X4, 2-level black/white on X3), autocontrast histogram stretching, and contrast boost. Light Novel mode rotates/splits landscape images | +| 7 | **Process images** — converts all images to baseline JPEG, resizes to the device screen (480x800 for X4, 528x792 for X3; max 1024x1024), applies 4-level grayscale quantization with Floyd-Steinberg dithering, autocontrast histogram stretching, and contrast boost. Light Novel mode rotates/splits landscape images | | 8 | **Fix SVG covers** — unwraps SVG-wrapped cover images (common in Gutenberg/store EPUBs) | | 9 | **Generate cover** — creates a title/author cover image if the book doesn't have one | | 10 | **Update references** — rewrites all internal hrefs and srcs to match renamed image files | @@ -57,7 +57,7 @@ The optimizer is tuned for these hardware constraints: | Spec | X4 | X3 | |------|----|----| | Display | 480x800 portrait (4.3" panel) | 528x792 portrait (3.7" panel) | -| Grayscale | 4 levels (SSD1677): black, dark gray, light gray, white | 1-bit black/white (stock firmware reads only the first bit plane) | +| Grayscale | 4 levels (SSD1677): black, dark gray, light gray, white | Same 4-level SSD1677 hardware | | Processor | ESP32-C3, 160MHz | ESP32-C3 | | RAM | 380KB usable | 380KB usable | | Max image | 1024x1024 pixels | 1024x1024 pixels | @@ -66,13 +66,13 @@ The optimizer is tuned for these hardware constraints: Image fit boxes match the [CrossPoint reference converter](https://github.com/crosspoint-reader/crosspoint-reader) device profiles (X4: 480x800, X3: 528x792). The panels scan in landscape, but the readers display portrait — images sized to the portrait box render sharp without reader-side upscaling. -Note: the X3's SSD1677 controller is hardware-capable of 4-level grayscale, but stock firmware only renders black/white — so the X3 profile dithers to 2 levels, which looks better than letting the device discard gray data. (Custom firmware like [CrossPoint Reader](https://github.com/crosspoint-reader/crosspoint-reader) adds grayscale support; if you run it, the X4 profile's 4-level output also works on an X3.) +Note: stock X3 firmware does not render images inside EPUBs at all (hardware-verified — original and processed files alike show blank image pages). X3 image output therefore targets custom firmware such as [CrossPoint](https://github.com/crosspoint-reader/crosspoint-reader)/CrossInk, which renders 4-level grayscale on the X3's panel. ## Image processing details - **Format**: All images converted to baseline JPEG (progressive breaks many e-ink readers) - **Resize**: Fit within the device screen (480x800 X4, 528x792 X3, portrait), hard clamp at 1024x1024 -- **Grayscale**: X4 — 4-level quantization matching SSD1677 palette (0, 85, 170, 255); X3 — 2-level black/white (0, 255); both with Floyd-Steinberg dithering +- **Grayscale**: 4-level quantization matching the SSD1677 palette (0, 85, 170, 255) with Floyd-Steinberg dithering (both devices) - **Contrast**: Auto-histogram stretching (`ImageOps.autocontrast`) followed by 1.5x contrast boost - **Subsampling**: 4:2:0 for grayscale (all RGB channels identical, saves ~15-20%), 4:4:4 for color - **Transparency**: Alpha composited onto white background diff --git a/epub_processor.py b/epub_processor.py index 218b317..0ad686d 100644 --- a/epub_processor.py +++ b/epub_processor.py @@ -38,7 +38,7 @@ @dataclass class ProcessingOptions: """All user-configurable processing options.""" - device: str = DEFAULT_DEVICE # 'x4' (480x800, 4-level) or 'x3' (528x792, B/W) + device: str = DEFAULT_DEVICE # 'x4' (480x800) or 'x3' (528x792), both 4-level gray grayscale: bool = True contrast_boost: bool = True contrast_factor: float = 1.5 # Higher default for low-bit-depth displays diff --git a/image_processor.py b/image_processor.py index 2e06b80..9c9b7fe 100644 --- a/image_processor.py +++ b/image_processor.py @@ -6,9 +6,10 @@ Device profiles (display orientation, portrait): X4 (SSD1677 controller): - Display: 480x800, 4-level grayscale (black, dark gray, light gray, white) - X3 (SSD1677 controller, stock firmware): - - Display: 528x792, 1-bit black/white (firmware only reads the first - bit plane, so gray data is discarded — dither to 2 levels instead) + X3 (SSD1677 controller): + - Display: 528x792, same 4-level grayscale hardware + - Note: stock X3 firmware does not render EPUB images at all; image + output targets CrossPoint-family firmware Both: - Max image: 1024x1024 - RAM: 380KB — smaller images = faster rendering @@ -36,8 +37,10 @@ 'x3': { 'width': 528, 'height': 792, - # Stock X3 firmware renders 1-bit black/white only - 'gray_levels': [0, 255], + # Same SSD1677 4-level grayscale as the X4. Stock X3 firmware does + # not render EPUB images at all (hardware-verified), so image output + # targets CrossPoint-family firmware, which renders grayscale. + 'gray_levels': [0, 85, 170, 255], 'label': 'Xteink X3', }, } diff --git a/static/app.js b/static/app.js index 27cc891..2ee4a7b 100644 --- a/static/app.js +++ b/static/app.js @@ -12,7 +12,7 @@ const qualityValue = document.getElementById('quality-value'); const downloadAllBtn = document.getElementById('download-all-btn'); let uploadedFiles = []; // {task_id, filename, metadata, file_size} -let selectedDevice = 'x4'; // 'x4' (480x800, 4-level gray) or 'x3' (528x792, B/W) +let selectedDevice = 'x4'; // 'x4' (480x800) or 'x3' (528x792), both 4-level gray // ==================== Upload ==================== @@ -153,13 +153,6 @@ document.querySelectorAll('.device-btn').forEach(btn => { document.querySelectorAll('.device-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedDevice = btn.dataset.device; - - const hint = document.getElementById('grayscale-hint'); - if (hint) { - hint.textContent = selectedDevice === 'x3' - ? 'Dither images to black & white' - : 'Convert images for e-ink display'; - } }); }); diff --git a/templates/index.html b/templates/index.html index 5421129..0477a6c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -33,13 +33,13 @@

EPUB Kit

-

Drop in any EPUB and get back a clean, optimized file tuned for your Xteink reader — the X4's 480×800 4-level grayscale display or the X3's 528×792 black & white display. A 20-step pipeline handles everything automatically.

+

Drop in any EPUB and get back a clean, optimized file tuned for your Xteink reader — the X4's 480×800 or the X3's 528×792 4-level grayscale display. A 20-step pipeline handles everything automatically.

🖼
Images - Grayscale or B/W dithering per device, resized to screen, baseline JPEG + 4-level grayscale with dithering, resized to your device's screen, baseline JPEG
@@ -100,11 +100,11 @@

Options

@@ -132,7 +132,7 @@

Options

Grayscale - Convert images for e-ink display + Convert images for e-ink display
@@ -240,6 +240,6 @@

Results

- + From 035e784ae478eb083ffa37e198bed11c509ded74 Mon Sep 17 00:00:00 2001 From: casualducko Date: Sat, 6 Jun 2026 15:31:10 -0400 Subject: [PATCH 3/6] Remove PR draft accidentally included in previous commit Co-Authored-By: Claude Opus 4.8 (1M context) --- PR_DRAFT.md | 66 ----------------------------------------------------- 1 file changed, 66 deletions(-) delete mode 100644 PR_DRAFT.md diff --git a/PR_DRAFT.md b/PR_DRAFT.md deleted file mode 100644 index 37c9475..0000000 --- a/PR_DRAFT.md +++ /dev/null @@ -1,66 +0,0 @@ -# Add Xteink X3 support + fix image fit-box orientation - -## Summary - -Two related changes, both validated on real hardware (X4 + X3): - -1. **X3 support** (additive) — a device toggle in the UI with an X3 profile: 528×792 screen, same 4-level SSD1677 grayscale treatment as the X4. -2. **Portrait fit-box fix** (changes existing X4 output) — images now fit the display orientation (X4: 480×800, X3: 528×792) instead of the panel scan orientation (800×480). On-device, this is the difference between a cover that fills the screen and one that renders as a small box in the corner. - -## 1. X3 support - -The X3's display is **528×792 portrait** (3.68", ~259 PPI — confirmed by the PPI math against Xteink's official spec; some review sites incorrectly report it as sharing the X4's 480×800). Same SSD1677 controller and ESP32-C3 as the X4, so the existing 4-level grayscale pipeline applies unchanged — the profile only differs in dimensions. - -Implementation: - -- `image_processor.py`: `DEVICE_PROFILES` dict, `gray_levels` on `ImageOptions`, `_quantize_to_levels()` generalizes the existing 4-level quantizer to any palette (machinery for future devices with different gray depths) -- `epub_processor.py`: `ProcessingOptions.device`; generated covers use device dimensions and are quantized to the device palette so they match the display gamut -- `app.py`: `device=x4|x3` query param on `/process/{task_id}` (validated, defaults to x4) -- UI: X4/X3 segmented toggle above the presets; device choice is independent of the Quick/Full/Custom preset - -**A finding worth documenting** (now in the README): stock X3 firmware does not render images inside EPUBs *at all*. Hardware-tested with an unmodified store EPUB and a diagnostic EPUB covering 8 encoding variants (odd/even widths, optimized/standard Huffman tables, 4:2:0/4:4:4 subsampling, single-channel JPEG, small dimensions, PNG, BMP) — every image page renders blank on stock, including the publisher's original JPEG. X3 image output therefore targets CrossPoint-family firmware, which renders 4-level grayscale on the X3 panel. - -## 2. Portrait fit-box fix - -The existing code fit images within **800×480** (panel scan orientation). But the readers display portrait — CrossPoint's converter profiles are: - -```js -const DEVICE_PROFILES = { - X4: { width: 480, height: 800, label: 'X4' }, - X3: { width: 528, height: 792, label: 'X3' }, -``` - -and the firmware's `XtcTypes.h` defines `DISPLAY_WIDTH = 480; DISPLAY_HEIGHT = 800` (its debug tooling rotates the raw landscape framebuffer 270° for screenshots). - -With the old 800×480 box, a portrait cover was capped at 480px tall (e.g. 320×480), and the firmware renders images at **native size without upscaling** — so covers drew as a small box with the rest of the screen blank. With the portrait box the same cover comes out 480×721 and fills the display. - -Photos from an X4 (same book, same cover page): - -| Old 800×480 box | New 480×800 box | -|---|---| -| *(photo: cover renders ~40% of screen)* | *(photo: cover fills screen)* | - -This also fixes Light Novel mode: rotated landscape pages now fill the full 480×800 instead of being squeezed to 480px tall. - -## Hardware validation - -| Test | Device / firmware | Result | -|---|---|---| -| Portrait vs landscape fit box A/B | X4, CrossInk (CrossPoint fork) | Portrait fills screen; landscape renders small (photos above) | -| X3-processed EPUB | X3, CrossInk | Images render with correct dimensions and tone | -| X3-processed EPUB | X3, **stock** | Image pages blank — stock does not render EPUB images (see finding above; text renders fine) | -| Original unprocessed EPUB (control) | X3, stock | Image pages equally blank — confirms stock limitation, not a pipeline regression | -| 8-variant encoding diagnostic | X3, stock | All blank — rules out odd-width/Huffman/subsampling/format/size causes | - -Pipeline-level verification (2.5MB store EPUB, 13 images): both profiles process in ~0.5s; all images baseline JPEG within the fit box; mimetype first ZIP entry stored uncompressed; all 30 XHTML files well-formed with identical word counts before/after; OPF/NCX parse cleanly. - -## Incidental changes - -- Batch ZIP renamed `x4_optimized_*` → `epubkit_optimized_*` (no longer X4-only) -- `*.epub` added to `.gitignore` (keeps local test books out of the repo) -- Cache-busters bumped on `style.css` / `app.js` - -## Compatibility notes - -- `ProcessingOptions` / `ImageOptions` field defaults are unchanged for existing callers; `device='x4'` is the default everywhere. -- The fit-box change affects existing X4 users' output (intentionally — see photos). Anyone who preferred the old behavior can pass custom `max_width`/`max_height` via `ImageOptions`. From 3a39a3399a17fb4ae5b0a3d68e48a31d57ae547c Mon Sep 17 00:00:00 2001 From: casualducko Date: Sat, 6 Jun 2026 15:31:10 -0400 Subject: [PATCH 4/6] Document stock firmware EPUB image limitation for X4 as well Stock X4 firmware tested with the same control + 8-variant diagnostic as the X3: no EPUB images render on either device. EPUB image optimization benefits CrossPoint-family firmware only. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d03a2f..da801e1 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ The optimizer is tuned for these hardware constraints: Image fit boxes match the [CrossPoint reference converter](https://github.com/crosspoint-reader/crosspoint-reader) device profiles (X4: 480x800, X3: 528x792). The panels scan in landscape, but the readers display portrait — images sized to the portrait box render sharp without reader-side upscaling. -Note: stock X3 firmware does not render images inside EPUBs at all (hardware-verified — original and processed files alike show blank image pages). X3 image output therefore targets custom firmware such as [CrossPoint](https://github.com/crosspoint-reader/crosspoint-reader)/CrossInk, which renders 4-level grayscale on the X3's panel. +Note: stock Xteink firmware (X3 and X4 alike) does not render images inside EPUBs at all — hardware-verified on both devices with unmodified store EPUBs and a diagnostic EPUB covering 8 encoding variants (JPEG parameter permutations, PNG, BMP); every image page renders blank while text renders normally. Image optimization therefore benefits custom firmware such as [CrossPoint](https://github.com/crosspoint-reader/crosspoint-reader)/CrossInk, which renders 4-level grayscale images on both panels. ## Image processing details From 0f6501b5c858fe37360d809e9bf6f64fcb97cf16 Mon Sep 17 00:00:00 2001 From: casualducko Date: Sat, 6 Jun 2026 15:31:10 -0400 Subject: [PATCH 5/6] Fix stale comments: no 2-level profile, no reader upscaling Co-Authored-By: Claude Opus 4.8 (1M context) --- image_processor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/image_processor.py b/image_processor.py index 9c9b7fe..07efe01 100644 --- a/image_processor.py +++ b/image_processor.py @@ -24,8 +24,9 @@ # Device screen profiles in display orientation (portrait), matching the # CrossPoint reference converter (X4: 480x800, X3: 528x792). The panels scan -# in landscape (e.g. 800x480) but the readers display portrait, so images -# must fit the portrait box or the reader upscales them blurrily. +# in landscape (e.g. 800x480) but the readers display portrait — and render +# images at native size without upscaling, so images sized to the old +# landscape box drew as a small box instead of filling the screen. DEVICE_PROFILES = { 'x4': { 'width': 480, @@ -114,8 +115,8 @@ def is_progressive_jpeg(image_bytes: bytes) -> bool: def _quantize_to_levels(img: Image.Image, levels: list[int]) -> Image.Image: """ Quantize grayscale image to the device's e-ink levels with - Floyd-Steinberg dithering (e.g. [0, 85, 170, 255] for X4, - [0, 255] for X3 black/white). + Floyd-Steinberg dithering (e.g. [0, 85, 170, 255] for the SSD1677 + 4-level palette; any level count works, e.g. [0, 255] for 1-bit). Uses PIL's built-in quantize with a custom palette for speed. """ # Build a grayscale palette image from the device levels From e640e94c2c5729b78f31b885c48ed3e8f2c0902d Mon Sep 17 00:00:00 2001 From: casualducko Date: Sat, 6 Jun 2026 15:49:30 -0400 Subject: [PATCH 6/6] Address review: explicit Optional annotation, validate device against DEVICE_PROFILES Co-Authored-By: Claude Opus 4.8 (1M context) --- app.py | 6 ++++-- image_processor.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 814d035..9bfb88a 100644 --- a/app.py +++ b/app.py @@ -19,6 +19,7 @@ from starlette.requests import Request from epub_processor import process_epub, extract_epub_metadata, ProcessingOptions, ProcessingReport +from image_processor import DEVICE_PROFILES app = FastAPI(title="epubkit") @@ -154,8 +155,9 @@ async def process_sse( if task["status"] == "processing": raise HTTPException(status_code=409, detail="Already processing") - if device not in ("x4", "x3"): - raise HTTPException(status_code=400, detail="Unknown device (expected 'x4' or 'x3')") + if device not in DEVICE_PROFILES: + allowed = ", ".join(f"'{d}'" for d in DEVICE_PROFILES) + raise HTTPException(status_code=400, detail=f"Unknown device (expected {allowed})") input_path = task["file_path"] out_dir = OUTPUT_DIR / task_id diff --git a/image_processor.py b/image_processor.py index 07efe01..148484a 100644 --- a/image_processor.py +++ b/image_processor.py @@ -321,7 +321,7 @@ def process_image(image_bytes: bytes, filename: str, options: ImageOptions = Non def generate_cover_image(title: str, author: str, width: int = X4_WIDTH, height: int = X4_HEIGHT, - gray_levels: list = None) -> bytes: + gray_levels: list[int] | None = None) -> bytes: """ Generate a simple cover image from title and author text. If gray_levels is given, quantize to the device palette (with dithering)