Skip to content

Commit c1b3562

Browse files
committed
CLI: Add EPUB optimizer for e-paper devices. Issue #2
1 parent 24067b3 commit c1b3562

File tree

7 files changed

+1144
-5
lines changed

7 files changed

+1144
-5
lines changed

Makefile

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Makefile for EPUB to XTC Converter & Optimizer
22

3-
.PHONY: all serve docker-serve tag help
3+
.PHONY: all serve docker-serve cli-install cli-convert cli-optimize tag help
44

55
PORT ?= 8000
66

@@ -18,6 +18,17 @@ docker-serve: ## Run in Docker (Ctrl+C to stop). Usage: make docker-serve [PORT=
1818
@echo "Running at http://localhost:$(PORT) (Ctrl+C to stop)"
1919
@docker run --rm -p $(PORT):8000 epub-to-xtc
2020

21+
## CLI:
22+
23+
cli-install: ## Install CLI dependencies
24+
@cd cli && npm install
25+
26+
cli-convert: ## Convert EPUB to XTC. Usage: make cli-convert INPUT=book.epub OUTPUT=book.xtc CONFIG=settings.json
27+
@cd cli && node index.js convert $(INPUT) -o $(OUTPUT) -c $(CONFIG)
28+
29+
cli-optimize: ## Optimize EPUB for e-paper. Usage: make cli-optimize INPUT=book.epub OUTPUT=optimized.epub CONFIG=settings.json
30+
@cd cli && node index.js optimize $(INPUT) -o $(OUTPUT) -c $(CONFIG)
31+
2132
## Release:
2233

2334
tag: ## Create and push a version tag (triggers GitHub release)

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,16 @@ node index.js convert ./epubs/ -o ./output/ -c settings.json
8181

8282
# Use XTCH format (2-bit grayscale)
8383
node index.js convert book.epub -f xtch -c settings.json
84+
85+
# Optimize single EPUB for e-paper
86+
node index.js optimize book.epub -o book_optimized.epub -c settings.json
87+
88+
# Optimize all EPUBs in a directory
89+
node index.js optimize ./epubs/ -o ./output/ -c settings.json
8490
```
8591

92+
Optimization options are configured in `settings.json` under the `optimizer` section. Set `recursive` to `true` to process subdirectories; use `include`/`exclude` glob patterns to filter files.
93+
8694
Example `settings.json`:
8795
```json
8896
{
@@ -92,7 +100,17 @@ Example `settings.json`:
92100
"lineHeight": 120,
93101
"textAlign": "justify",
94102
"hyphenation": { "enabled": true, "language": "en" },
95-
"output": { "format": "xtc", "dithering": true, "ditherStrength": 0.7 }
103+
"output": { "format": "xtc", "dithering": true, "ditherStrength": 0.7 },
104+
"optimizer": {
105+
"removeCss": true,
106+
"stripFonts": true,
107+
"grayscale": true,
108+
"maxImageWidth": 480,
109+
"injectCss": true,
110+
"recursive": false,
111+
"include": "*.epub",
112+
"exclude": null
113+
}
96114
}
97115
```
98116

@@ -159,6 +177,7 @@ Then open http://localhost:8000 in your browser.
159177
│ ├── converter.js # WASM integration and conversion logic
160178
│ ├── encoder.js # XTG/XTH/XTC format encoding
161179
│ ├── dither.js # Floyd-Steinberg dithering
180+
│ ├── optimizer.js # EPUB optimizer for e-paper
162181
│ ├── settings.js # Settings management
163182
│ └── package.json # CLI dependencies
164183
├── docs/
@@ -182,6 +201,8 @@ Then open http://localhost:8000 in your browser.
182201
- Node.js 18+
183202
- [Commander](https://github.com/tj/commander.js) - CLI framework
184203
- [JSZip](https://stuk.github.io/jszip/) - ZIP file handling
204+
- [sharp](https://sharp.pixelplumbing.com/) - Image processing (optimizer)
205+
- [minimatch](https://github.com/isaacs/minimatch) - Glob pattern matching (optimizer)
185206
- CREngine WASM (shared with web app)
186207

187208
## Browser Support

cli/index.js

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
const { program } = require('commander');
99
const fs = require('fs');
1010
const path = require('path');
11-
const { loadSettings, resolveSettings, validateSettings, generateDefaultConfig } = require('./settings');
11+
const { minimatch } = require('minimatch');
12+
const { loadSettings, resolveSettings, validateSettings, validateOptimizerSettings, generateDefaultConfig } = require('./settings');
1213
const { convertEpub, getOutputPath, cleanup } = require('./converter');
14+
const { optimizeEpub } = require('./optimizer');
1315

1416
program
1517
.name('epub-to-xtc')
@@ -87,6 +89,152 @@ program
8789
console.log('\nImportant: Edit the file to set font.path to your TTF/OTF font file.');
8890
});
8991

92+
program
93+
.command('optimize <input>')
94+
.description('Optimize EPUB file(s) for e-paper devices')
95+
.option('-o, --output <path>', 'Output file or directory')
96+
.option('-c, --config <path>', 'Path to settings JSON file')
97+
.action(async (input, options) => {
98+
try {
99+
const settings = loadSettings(options.config);
100+
const opts = settings.optimizer || {};
101+
102+
const errors = validateOptimizerSettings(settings);
103+
if (errors.length > 0) {
104+
console.error('Configuration errors:');
105+
errors.forEach(e => console.error(` - ${e}`));
106+
process.exit(1);
107+
}
108+
109+
const inputPath = path.resolve(input);
110+
111+
if (!fs.existsSync(inputPath)) {
112+
console.error(`Input not found: ${inputPath}`);
113+
process.exit(1);
114+
}
115+
116+
const stat = fs.statSync(inputPath);
117+
118+
if (stat.isDirectory()) {
119+
await optimizeDirectory(inputPath, options.output, opts);
120+
} else if (stat.isFile() && inputPath.endsWith('.epub')) {
121+
await optimizeSingleFile(inputPath, options.output, opts);
122+
} else {
123+
console.error('Input must be an EPUB file or directory containing EPUB files');
124+
process.exit(1);
125+
}
126+
127+
} catch (err) {
128+
console.error(`Error: ${err.message}`);
129+
process.exit(1);
130+
}
131+
});
132+
133+
function formatSize(bytes) {
134+
return (bytes / 1024).toFixed(1) + ' KB';
135+
}
136+
137+
async function optimizeSingleFile(inputPath, outputPath, opts) {
138+
if (!outputPath) {
139+
const dir = path.dirname(inputPath);
140+
const ext = path.extname(inputPath);
141+
const base = path.basename(inputPath, ext);
142+
outputPath = path.join(dir, `${base}_optimized${ext}`);
143+
} else if (fs.existsSync(outputPath) && fs.statSync(outputPath).isDirectory()) {
144+
outputPath = path.join(outputPath, path.basename(inputPath));
145+
} else {
146+
outputPath = path.resolve(outputPath);
147+
}
148+
149+
const filename = path.basename(inputPath);
150+
console.log(`Optimizing: ${filename}`);
151+
152+
const result = await optimizeEpub(inputPath, outputPath, opts);
153+
154+
console.log(` Output: ${result.outputPath}`);
155+
console.log(` Size: ${formatSize(result.originalSize)} -> ${formatSize(result.optimizedSize)} (${result.reductionPercent}% reduction)`);
156+
}
157+
158+
/**
159+
* Collect EPUB files from a directory, optionally recursive
160+
*/
161+
function collectEpubFiles(dir, opts, basedir) {
162+
basedir = basedir || dir;
163+
let results = [];
164+
const entries = fs.readdirSync(dir, { withFileTypes: true });
165+
const include = opts.include || '*.epub';
166+
const exclude = opts.exclude || null;
167+
168+
for (const entry of entries) {
169+
const fullPath = path.join(dir, entry.name);
170+
const relPath = path.relative(basedir, fullPath);
171+
172+
if (entry.isDirectory() && opts.recursive) {
173+
results = results.concat(collectEpubFiles(fullPath, opts, basedir));
174+
} else if (entry.isFile()) {
175+
if (!minimatch(entry.name, include)) continue;
176+
if (exclude && minimatch(entry.name, exclude)) continue;
177+
results.push({ absolute: fullPath, relative: relPath });
178+
}
179+
}
180+
181+
return results;
182+
}
183+
184+
async function optimizeDirectory(inputDir, outputDir, opts) {
185+
const files = collectEpubFiles(inputDir, opts, inputDir);
186+
187+
if (files.length === 0) {
188+
console.error('No EPUB files found in directory');
189+
process.exit(1);
190+
}
191+
192+
const inPlace = !outputDir;
193+
if (!outputDir) {
194+
outputDir = inputDir;
195+
} else {
196+
outputDir = path.resolve(outputDir);
197+
if (!fs.existsSync(outputDir)) {
198+
fs.mkdirSync(outputDir, { recursive: true });
199+
}
200+
}
201+
202+
console.log(`Optimizing ${files.length} EPUB file(s)...\n`);
203+
204+
let successCount = 0;
205+
let failCount = 0;
206+
207+
for (let i = 0; i < files.length; i++) {
208+
const file = files[i];
209+
// Preserve relative directory structure in output
210+
let outputPath;
211+
if (inPlace) {
212+
// Add _optimized suffix to avoid overwriting originals
213+
const ext = path.extname(file.relative);
214+
const base = file.relative.slice(0, -ext.length);
215+
outputPath = path.join(outputDir, `${base}_optimized${ext}`);
216+
} else {
217+
outputPath = path.join(outputDir, file.relative);
218+
}
219+
220+
console.log(`[${i + 1}/${files.length}] ${file.relative}`);
221+
222+
try {
223+
const result = await optimizeEpub(file.absolute, outputPath, opts);
224+
225+
console.log(` Output: ${path.basename(result.outputPath)}`);
226+
console.log(` Size: ${formatSize(result.originalSize)} -> ${formatSize(result.optimizedSize)} (${result.reductionPercent}% reduction)\n`);
227+
successCount++;
228+
229+
} catch (err) {
230+
console.log(` Error: ${err.message}\n`);
231+
failCount++;
232+
}
233+
}
234+
235+
console.log(`\nOptimization complete: ${successCount} succeeded, ${failCount} failed`);
236+
}
237+
90238
async function convertSingleFile(inputPath, outputPath, settings) {
91239
// Determine output path
92240
if (!outputPath) {

0 commit comments

Comments
 (0)