Automatically (or manually) optimizes EPUB files for e-readers like the XTEINK X4. Converts images to baseline JPEG, applies grayscale, resizes to fit the display, and handles wide-image splitting with rotation. Cover images receive automatic contrast enhancement so light-colored covers (e.g. white text on a light background) render clearly on e-ink without any extra flags. The EPUB optimizer was forked from the built-in browser EPUB optimizer within Crosspoint. This repo just creates a standalone browser and node.js version that can be run via a shell script to automate the process.
Three ways to use it:
- Automatically with a watcher via Docker Compose or Systemd
- Manually via a browser-based GUI (
browser/index.html), no install required. - Manually by running the Node.js CLI (
cli/optimize.js).
I have two e-readers: a Kindle Colorsoft and an XTEINK X4. I wanted a single drop folder where adding a book would automatically populate both devices' libraries: the original full-color EPUB going to Calibre for the Kindle, and a grayscale-optimized version landing in a separate library for the X4. That X4 library can be served by any OPDS server that doesn't require a Calibre database so the book shows up ready to read with no manual steps.
- Drop an
.epubinto one folder and have it appear in two separately managed libraries automatically - The original is copied to your Calibre watch folder and handled from there as normal
- A grayscale-optimized copy is written to a separate X4 library, ready to serve via OPDS
- Single-library mode also supported: leave
CALIBRE_WATCH_FOLDERunset and only the optimized copy is produced
Note: The default settings are for the XTEINK X4 where the screen settings are 480x800. If you have a different device size, you need to change those settings in
cli/optimize.jsor in the browser when using the browser version.
The repo includes a docker-compose.yml that runs a two-service pipeline without installing Node.js or inotify-tools on the host.
cp .env.example .env
# Edit .env - set BOOKDROP_DIR, WATCHER_DEST_DIR, and optionally CALIBRE_WATCH_FOLDERThe containers use fixed internal paths (/bookdrop, /output, /destination, /calibre). You only need to set the host-side paths in .env. EPUB_OUTPUT_DIR is handled internally via a shared Docker volume between the two services so you do not need to set it.
The optimizer writes finished EPUBs to an intermediate Docker volume (output), and the watcher moves them from there to WATCHER_DEST_DIR. This split exists because inotify is unreliable on Windows NTFS paths (e.g. /mnt/c/...) even inside Docker on WSL2 due to a kernel-level limitation. By keeping the handoff point on a pure Linux volume, the watcher can reliably detect new files and handle moving the file to a potential Windows-backed destination.
If your WATCHER_DEST_DIR is a plain Linux path (e.g. another Linux directory or a Linux-backed Docker bind mount), the two-service split is unnecessary. You can remove the epub-watcher service from docker-compose.yml and set the optimizer's EPUB_OUTPUT_DIR directly to your destination path.
docker compose up -ddocker compose logs -f epub-optimizer
docker compose logs -f epub-watcherdocker compose downThe scripts/ folder contains two systemd user services that build a fully automated pipeline:
BOOKDROP_DIR →[epub-optimizer]→ EPUB_OUTPUT_DIR →[epub-watcher]→ WATCHER_DEST_DIR
- epub-optimizer - polls a bookdrop folder for
.epubfiles, runsoptimize.json each, and writes the result to an output folder. Uses polling instead ofinotifyso it works on Windows NTFS mounts (/mnt/c/) under WSL2. - epub-watcher - watches the output folder with
inotifywaitand moves finished files to a final destination (e.g. a Calibre/OPDS library folder).
- Node.js (run
node --versionto verify) inotify-tools(the watcher installer will install this automatically if missing)- systemd user session enabled (standard on most modern Linux distros and WSL2 with systemd)
Copy the example config and fill in your paths:
mkdir -p ~/.config/epub-optimizer
cp .env.example ~/.config/epub-optimizer/.envEdit ~/.config/epub-optimizer/.env:
| Variable | Description |
|---|---|
BOOKDROP_DIR |
Drop .epub files here to trigger processing |
CALIBRE_WATCH_FOLDER |
Optional - Calibre watch folder; files are copied here before optimization (for use when you want a separate workflow for Calibre) |
OPTIMIZER_SCRIPT |
Absolute path to cli/optimize.js in this repo |
EPUB_OUTPUT_DIR |
Where the optimizer writes finished EPUBs |
WATCHER_DEST_DIR |
Where the watcher moves finished EPUBs (your final X4 library folder) |
OPTIMIZER_LOG_FILE |
Log path for the optimizer service (default: ~/.local/log/epub-optimizer.log) |
WATCHER_LOG_FILE |
Log path for the watcher service (default: ~/.local/log/epub-watcher.log) |
POLL_INTERVAL |
Seconds between bookdrop scans (default: 5) |
KEEP_DAYS |
Days to keep files in bookdrop/processed/ before auto-deletion (default: 5) |
EPUB_NORMALIZE |
Optional - set to 1 to apply luminance normalization to interior images (see CLI: contrast options) |
EPUB_CONTRAST |
Optional - set to a multiplier like 1.3 to boost contrast on interior images (see CLI: contrast options) |
Run the installers from the scripts/ directory. Install the watcher first! epub-optimizer.service has After=epub-watcher.service in its unit file, so systemd expects the watcher unit to exist before the optimizer is registered.
cd scripts
# Step 1: watcher (moves optimized files to their final destination)
./install-epub-watcher.sh
# Step 2: optimizer (polls bookdrop, runs optimize.js)
./install-epub-optimizer.shEach installer will:
- Check for dependencies
- Create the config file from
.env.exampleif it doesn't exist yet - Copy scripts to
~/.local/bin/ - Register and start the systemd user service
Drop any .epub file into your BOOKDROP_DIR. The optimizer picks it up within POLL_INTERVAL seconds, processes it, and the watcher moves the result to WATCHER_DEST_DIR.
Inside BOOKDROP_DIR you'll find three subfolders that track state:
| Subfolder | Meaning |
|---|---|
processing/ |
File is currently being optimized |
processed/ |
Successfully optimized; auto-deleted after KEEP_DAYS |
failed/ |
Optimizer returned an error, check the logs |
# Status of both services
systemctl --user status epub-optimizer epub-watcher
# Follow live logs
journalctl --user -u epub-optimizer -f
journalctl --user -u epub-watcher -f
# Restart
systemctl --user restart epub-optimizer epub-watcher
# Stop
systemctl --user stop epub-optimizer epub-watcherOpen browser/index.html directly in a browser. Everything runs locally, no files leave your machine.
- Drop one or more
.epubfiles onto the drop zone (or click to select) - Adjust settings if needed
- Click Optimize & Download
| Setting | Default | Description |
|---|---|---|
| JPEG Quality | 85% | Compression quality for converted images |
| Max Width | 480 px | Images wider than this are resized |
| Max Height | 800 px | Images taller than this are resized |
| Split Mode | None | None / H-Split (rotate & split wide images) / V-Split (split tall images) |
| Overlap | 5% | Overlap between split halves |
| Rotation | Clockwise | Direction images are rotated before an H-Split |
| Grayscale | On | Convert images to grayscale |
Cover contrast enhancement runs automatically in the browser version as well — no setting required.
cd cli
npm installnode optimize.js [options] <input.epub ...>
node optimize.js [options] <directory>| Flag | Default | Description |
|---|---|---|
-o, --output <dir> |
./optimized |
Output directory |
-q, --quality <n> |
85 |
JPEG quality (1-100) |
--no-grayscale |
- | Disable grayscale conversion |
-n, --normalize |
- | Normalize luminance range on interior images |
--contrast <n> |
- | Contrast multiplier for interior images (e.g. 1.2, 1.5) |
-W, --max-width <n> |
480 |
Max image width in px |
-H, --max-height <n> |
800 |
Max image height in px |
--split <mode> |
none |
Split mode: none, h-split, v-split |
--overlap <n> |
5 |
Overlap % for splits: 5/10/15/20/25 |
--rotation <cw|ccw> |
cw |
Rotation direction for H-Split |
--suffix <str> |
-optimized |
Suffix appended to output filename |
-v, --verbose |
- | Print per-image details |
--help |
- | Show help |
Cover images are automatically enhanced for e-ink regardless of any flags. The optimizer detects the cover via the OPF manifest and applies a dedicated pipeline: grayscale → normalize → gamma correction. This pushes light-colored backgrounds (e.g. teal, beige, pale yellow) visibly darker while keeping white text at full brightness, preventing the washed-out look that occurs on low-contrast e-ink displays. No configuration is required.
--normalize and --contrast apply only to interior images — the cover is always handled separately as described above.
--normalizestretches the image's existing luminance range to fill 0–255. Best for scanned books or older EPUBs where the source images look flat because they were never encoded at full range (e.g. whites that top out at 220 instead of 255).--contrast <n>applies a fixed linear boost pivoted around mid-gray. Best for images that are already full-range but still look soft on e-ink — values like1.2–1.3add punch without clipping.- Using both together first expands the range, then amplifies the separation. Use cautiously: values above
1.4can crush shadow and highlight detail.
# Standard optimization (cover enhanced automatically)
node optimize.js book.epub
# Interior images that look flat from scanning
node optimize.js --normalize book.epub
# Interior images that need more punch on e-ink
node optimize.js --contrast 1.2 book.epub
# Both, for scanned books with soft interiors
node optimize.js --normalize --contrast 1.2 book.epub
# Custom output and display size
node optimize.js -q 80 -W 600 -H 900 --output ./out book.epub