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..da801e1 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, 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 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 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 | @@ -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 | Same 4-level SSD1677 hardware | +| 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: 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 - **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**: 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/app.py b/app.py index f6f2a11..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") @@ -133,6 +134,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 +155,17 @@ async def process_sse( if task["status"] == "processing": raise HTTPException(status_code=409, detail="Already processing") + 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 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 +298,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 +312,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..0ad686d 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) or 'x3' (528x792), both 4-level gray 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..148484a 100644 --- a/image_processor.py +++ b/image_processor.py @@ -1,30 +1,61 @@ """ -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): + - 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 """ 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 — 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, + '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, + # 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', + }, +} +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 +64,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 +112,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 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 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 +180,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 +237,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 +245,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 +259,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 +320,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[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) + so gray borders/text survive low-bit-depth displays. + """ img = Image.new('RGB', (width, height), (255, 255, 255)) draw = ImageDraw.Draw(img) @@ -337,6 +390,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..2ee4a7b 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) or 'x3' (528x792), both 4-level gray // ==================== Upload ==================== @@ -146,6 +147,15 @@ 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; + }); +}); + // Preset profiles document.querySelectorAll('.preset-btn').forEach(btn => { btn.addEventListener('click', () => { @@ -271,6 +281,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 +297,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..0477a6c 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 or the X3's 528×792 4-level grayscale display. A 20-step pipeline handles everything automatically.

🖼
Images - 4-level grayscale with dithering, resized to screen, baseline JPEG + 4-level grayscale with dithering, resized to your device's screen, baseline JPEG
@@ -95,6 +95,20 @@

EPUB Kit

Options

+
+ Device +
+ + +
+
+
- +