Skip to content

Commit 59faa48

Browse files
committed
Add Node.js CLI for EPUB to XTC/XTCH conversion
- CLI tool in cli/ directory with commander for argument parsing - Supports single file and directory batch conversion - Uses JSON config file for settings (device, font, margins, dithering) - Shares CREngine WASM module with web app
1 parent ea582b7 commit 59faa48

File tree

8 files changed

+1128
-13
lines changed

8 files changed

+1128
-13
lines changed

README.md

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# EPUB to XTC Converter & Optimizer
22

3-
A browser-based tool for converting EPUB files to XTC/XTCH format and optimizing EPUBs for e-ink readers.
3+
A tool for converting EPUB files to XTC/XTCH format and optimizing EPUBs for e-ink readers. Available as a browser-based web app and Node.js CLI.
44

55
**[Live Demo](https://liashkov.site/epub-to-xtc-converter/)**
66

@@ -60,6 +60,42 @@ A browser-based tool for converting EPUB files to XTC/XTCH format and optimizing
6060
- Configure optimization options
6161
- Click "Optimize EPUBs" to download optimized files
6262

63+
### CLI Usage
64+
65+
For batch processing without a browser, use the Node.js CLI:
66+
67+
```bash
68+
cd cli
69+
npm install
70+
71+
# Generate default settings file
72+
node index.js init
73+
74+
# Edit settings.json to set font.path to your TTF/OTF font file
75+
76+
# Convert single file
77+
node index.js convert book.epub -o book.xtc -c settings.json
78+
79+
# Convert all EPUBs in a directory
80+
node index.js convert ./epubs/ -o ./output/ -c settings.json
81+
82+
# Use XTCH format (2-bit grayscale)
83+
node index.js convert book.epub -f xtch -c settings.json
84+
```
85+
86+
Example `settings.json`:
87+
```json
88+
{
89+
"device": "xteink-x4",
90+
"font": { "path": "./LiterataTT.ttf", "size": 34, "weight": 400 },
91+
"margins": { "left": 16, "top": 16, "right": 16, "bottom": 16 },
92+
"lineHeight": 120,
93+
"textAlign": "justify",
94+
"hyphenation": { "enabled": true, "language": "en" },
95+
"output": { "format": "xtc", "dithering": true, "ditherStrength": 0.7 }
96+
}
97+
```
98+
6399
## XTC/XTCH Format
64100

65101
- **XTC**: 1-bit monochrome pages (fast rendering, smaller files)
@@ -96,31 +132,43 @@ Then open http://localhost:8000 in your browser.
96132

97133
```
98134
/
99-
├── web/
100-
│ ├── index.html # Main HTML structure
101-
│ ├── style.css # Application styles
102-
│ ├── app.js # Main application logic
103-
│ ├── crengine.js # CREngine WASM loader
104-
│ ├── crengine.wasm # CREngine binary (CoolReader engine)
105-
│ └── dither-worker.js # Web Worker for Floyd-Steinberg dithering
135+
├── web/ # Browser-based web app
136+
│ ├── index.html # Main HTML structure
137+
│ ├── style.css # Application styles
138+
│ ├── app.js # Main application logic
139+
│ ├── crengine.js # CREngine WASM loader
140+
│ ├── crengine.wasm # CREngine binary (CoolReader engine)
141+
│ └── dither-worker.js # Web Worker for Floyd-Steinberg dithering
142+
├── cli/ # Node.js CLI tool
143+
│ ├── index.js # CLI entry point
144+
│ ├── converter.js # WASM integration and conversion logic
145+
│ ├── encoder.js # XTG/XTH/XTC format encoding
146+
│ ├── dither.js # Floyd-Steinberg dithering
147+
│ ├── settings.js # Settings management
148+
│ └── package.json # CLI dependencies
106149
├── docs/
107-
│ └── xtc-format-spec.md # XTC format specification
150+
│ └── xtc-format-spec.md # XTC format specification
108151
├── .github/
109152
│ └── workflows/
110-
│ └── deploy.yml # GitHub Pages deployment
153+
│ └── deploy.yml # GitHub Pages deployment
111154
├── LICENSE
112155
└── README.md
113156
```
114157

115158
## Dependencies
116159

160+
### Web App
117161
- [JSZip](https://stuk.github.io/jszip/) - ZIP file handling (loaded from CDN)
118162
- CREngine - EPUB rendering (bundled as WASM)
119-
120-
Fonts (loaded on demand from Google Fonts):
121-
- Literata, Lora, Merriweather, Source Serif 4, Noto Serif, Noto Sans, Open Sans, Roboto, EB Garamond, Crimson Pro
163+
- Google Fonts (loaded on demand): Literata, Lora, Merriweather, Source Serif 4, Noto Serif, Noto Sans, Open Sans, Roboto, EB Garamond, Crimson Pro
122164
- Custom TTF/OTF font upload also supported
123165

166+
### CLI
167+
- Node.js 18+
168+
- [Commander](https://github.com/tj/commander.js) - CLI framework
169+
- [JSZip](https://stuk.github.io/jszip/) - ZIP file handling
170+
- CREngine WASM (shared with web app)
171+
124172
## Browser Support
125173

126174
Requires a modern browser with:

cli/converter.js

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/**
2+
* Core EPUB to XTC/XTCH converter
3+
* Uses CREngine WASM for EPUB rendering
4+
*/
5+
6+
const fs = require('fs');
7+
const path = require('path');
8+
const { applyDithering, applyNegative } = require('./dither');
9+
const { encodeXTG, encodeXTH, buildXTCContainer } = require('./encoder');
10+
11+
let Module = null;
12+
let renderer = null;
13+
14+
/**
15+
* Initialize CREngine WASM module
16+
*/
17+
async function initWasm() {
18+
if (Module) return;
19+
20+
const wasmPath = path.join(__dirname, '..', 'web', 'crengine.js');
21+
22+
if (!fs.existsSync(wasmPath)) {
23+
throw new Error(`CREngine WASM not found at: ${wasmPath}`);
24+
}
25+
26+
// Load CREngine module
27+
const CREngine = require(wasmPath);
28+
Module = await CREngine();
29+
}
30+
31+
/**
32+
* Create renderer with specified dimensions
33+
*/
34+
function createRenderer(width, height) {
35+
if (!Module) {
36+
throw new Error('WASM module not initialized. Call initWasm() first.');
37+
}
38+
renderer = new Module.EpubRenderer(width, height);
39+
40+
// Disable built-in status bar
41+
renderer.configureStatusBar(false, false, false, false, false, false, false, false, false);
42+
43+
return renderer;
44+
}
45+
46+
/**
47+
* Register font from file
48+
*/
49+
async function registerFont(fontPath) {
50+
if (!renderer) {
51+
throw new Error('Renderer not initialized');
52+
}
53+
54+
const fontData = fs.readFileSync(fontPath);
55+
const fontName = path.basename(fontPath);
56+
57+
const ptr = Module.allocateMemory(fontData.length);
58+
Module.HEAPU8.set(new Uint8Array(fontData), ptr);
59+
renderer.registerFontFromMemory(ptr, fontData.length, fontName);
60+
Module.freeMemory(ptr);
61+
62+
return fontName;
63+
}
64+
65+
/**
66+
* Load EPUB file into renderer
67+
*/
68+
async function loadEpub(epubPath) {
69+
if (!renderer) {
70+
throw new Error('Renderer not initialized');
71+
}
72+
73+
const epubData = fs.readFileSync(epubPath);
74+
75+
const ptr = Module.allocateMemory(epubData.length);
76+
Module.HEAPU8.set(new Uint8Array(epubData), ptr);
77+
78+
try {
79+
renderer.loadEpubFromMemory(ptr, epubData.length);
80+
} finally {
81+
Module.freeMemory(ptr);
82+
}
83+
84+
return {
85+
pageCount: renderer.getPageCount(),
86+
info: renderer.getDocumentInfo() || {},
87+
toc: renderer.getToc() || []
88+
};
89+
}
90+
91+
/**
92+
* Apply rendering settings
93+
*/
94+
function applySettings(settings) {
95+
if (!renderer) {
96+
throw new Error('Renderer not initialized');
97+
}
98+
99+
const { margins, font, lineHeight, textAlignValue, hyphenation } = settings;
100+
101+
renderer.setMargins(
102+
margins.left,
103+
margins.top,
104+
margins.right,
105+
margins.bottom
106+
);
107+
renderer.setFontSize(font.size);
108+
renderer.setFontWeight(font.weight);
109+
renderer.setInterlineSpace(lineHeight);
110+
renderer.setTextAlign(textAlignValue);
111+
112+
if (hyphenation.enabled) {
113+
renderer.setHyphenation(2); // Dictionary-based
114+
if (renderer.setHyphenationLanguage) {
115+
renderer.setHyphenationLanguage(hyphenation.language);
116+
}
117+
} else {
118+
renderer.setHyphenation(0); // Disabled
119+
}
120+
}
121+
122+
/**
123+
* Render a single page
124+
*/
125+
function renderPage(pageNum) {
126+
if (!renderer) {
127+
throw new Error('Renderer not initialized');
128+
}
129+
130+
renderer.goToPage(pageNum);
131+
renderer.renderCurrentPage();
132+
133+
const frameBuffer = renderer.getFrameBuffer();
134+
if (!frameBuffer || frameBuffer.length === 0) {
135+
throw new Error(`Empty frame buffer for page ${pageNum}`);
136+
}
137+
138+
// Copy buffer (frame buffer may be reused by WASM)
139+
return new Uint8ClampedArray(frameBuffer);
140+
}
141+
142+
/**
143+
* Convert single EPUB to XTC/XTCH
144+
*/
145+
async function convertEpub(epubPath, outputPath, settings, progressCallback) {
146+
const { width, height, output } = settings;
147+
const isHQ = output.format === 'xtch';
148+
const bits = isHQ ? 2 : 1;
149+
150+
// Initialize and setup
151+
await initWasm();
152+
createRenderer(width, height);
153+
154+
// Register font
155+
await registerFont(settings.font.path);
156+
157+
// Load EPUB
158+
const { pageCount, info, toc } = await loadEpub(epubPath);
159+
160+
if (pageCount === 0) {
161+
throw new Error('EPUB has no pages');
162+
}
163+
164+
// Apply settings after loading (affects pagination)
165+
applySettings(settings);
166+
167+
// Re-get page count after settings (pagination may change)
168+
const totalPages = renderer.getPageCount();
169+
170+
// Render all pages
171+
const pages = [];
172+
for (let i = 0; i < totalPages; i++) {
173+
// Render page
174+
let imageData = renderPage(i);
175+
176+
// Apply dithering if enabled
177+
if (output.dithering) {
178+
imageData = applyDithering(imageData, width, height, bits, output.ditherStrength);
179+
}
180+
181+
// Apply negative if enabled
182+
if (output.negative) {
183+
applyNegative(imageData);
184+
}
185+
186+
// Encode page
187+
const encoded = isHQ
188+
? encodeXTH(imageData, width, height)
189+
: encodeXTG(imageData, width, height);
190+
pages.push(encoded);
191+
192+
// Progress callback
193+
if (progressCallback) {
194+
progressCallback(i + 1, totalPages);
195+
}
196+
}
197+
198+
// Build container
199+
const metadata = {
200+
title: info.title || path.basename(epubPath, '.epub'),
201+
author: info.author || info.authors || ''
202+
};
203+
204+
const container = buildXTCContainer(pages, metadata, toc, width, height, isHQ);
205+
206+
// Write output
207+
fs.writeFileSync(outputPath, container);
208+
209+
return {
210+
outputPath,
211+
pageCount: totalPages,
212+
format: output.format
213+
};
214+
}
215+
216+
/**
217+
* Get output path for an EPUB file
218+
*/
219+
function getOutputPath(inputPath, outputDir, format) {
220+
const basename = path.basename(inputPath, '.epub');
221+
const extension = format === 'xtch' ? '.xtch' : '.xtc';
222+
return path.join(outputDir, basename + extension);
223+
}
224+
225+
/**
226+
* Cleanup renderer resources
227+
*/
228+
function cleanup() {
229+
if (renderer) {
230+
renderer = null;
231+
}
232+
}
233+
234+
module.exports = {
235+
initWasm,
236+
createRenderer,
237+
registerFont,
238+
loadEpub,
239+
applySettings,
240+
renderPage,
241+
convertEpub,
242+
getOutputPath,
243+
cleanup
244+
};

0 commit comments

Comments
 (0)