pdf2epub 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -121
- package/package.json +9 -44
- package/pdf2epub.js +624 -0
- package/LICENSE +0 -22
- package/bin/pdf2epub.js +0 -14
- package/lib/cli.js +0 -134
- package/lib/paths.js +0 -44
- package/lib/python.js +0 -76
- package/lib/setup.js +0 -152
- package/vendor/pdf2epub/LICENSE +0 -9
- package/vendor/pdf2epub/README.md +0 -217
- package/vendor/pdf2epub/UPSTREAM.json +0 -8
- package/vendor/pdf2epub/main.py +0 -118
- package/vendor/pdf2epub/modules/__init__.py +0 -0
- package/vendor/pdf2epub/modules/mark2epub.py +0 -696
- package/vendor/pdf2epub/modules/pdf2md.py +0 -172
- package/vendor/pdf2epub/modules/postprocessing/__init__.py +0 -0
- package/vendor/pdf2epub/modules/postprocessing/prompt.txt +0 -41
- package/vendor/pdf2epub/modules/postprocessing/template.py +0 -177
- package/vendor/pdf2epub/requirements.txt +0 -8
package/pdf2epub.js
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pdf2epub - Convert a PDF into a reflowable EPUB 3 ebook.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node pdf2epub.js input.pdf [-o output.epub] [options]
|
|
7
|
+
*
|
|
8
|
+
* Options:
|
|
9
|
+
* -o, --output <file> Output path (default: same name with .epub)
|
|
10
|
+
* -t, --title <text> Override book title
|
|
11
|
+
* -a, --author <text> Override author
|
|
12
|
+
* -l, --lang <code> Language code (default: en)
|
|
13
|
+
* -c, --cover <image> Cover image (jpg/png) to embed
|
|
14
|
+
* --split <mode> Chapter split: auto | outline | headings | pages (default: auto)
|
|
15
|
+
* --pages-per-chapter <n> Used when split mode is "pages" (default: 10)
|
|
16
|
+
* --keep-headers Do not strip repeating page headers/footers
|
|
17
|
+
* --font-scale <n> Heading detection threshold vs body font (default: 1.15)
|
|
18
|
+
* -h, --help Show help
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs/promises';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
24
|
+
import JSZip from 'jszip';
|
|
25
|
+
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// CLI
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const opts = {
|
|
33
|
+
input: null, output: null, title: null, author: null, lang: 'en',
|
|
34
|
+
cover: null, split: 'auto', pagesPerChapter: 10, keepHeaders: false,
|
|
35
|
+
fontScale: 1.15,
|
|
36
|
+
};
|
|
37
|
+
for (let i = 0; i < argv.length; i++) {
|
|
38
|
+
const a = argv[i];
|
|
39
|
+
const next = () => {
|
|
40
|
+
if (i + 1 >= argv.length) throw new Error(`Missing value for ${a}`);
|
|
41
|
+
return argv[++i];
|
|
42
|
+
};
|
|
43
|
+
switch (a) {
|
|
44
|
+
case '-o': case '--output': opts.output = next(); break;
|
|
45
|
+
case '-t': case '--title': opts.title = next(); break;
|
|
46
|
+
case '-a': case '--author': opts.author = next(); break;
|
|
47
|
+
case '-l': case '--lang': opts.lang = next(); break;
|
|
48
|
+
case '-c': case '--cover': opts.cover = next(); break;
|
|
49
|
+
case '--split': opts.split = next(); break;
|
|
50
|
+
case '--pages-per-chapter': opts.pagesPerChapter = Math.max(1, parseInt(next(), 10) || 10); break;
|
|
51
|
+
case '--keep-headers': opts.keepHeaders = true; break;
|
|
52
|
+
case '--font-scale': opts.fontScale = parseFloat(next()) || 1.15; break;
|
|
53
|
+
case '-h': case '--help': opts.help = true; break;
|
|
54
|
+
default:
|
|
55
|
+
if (a.startsWith('-')) throw new Error(`Unknown option: ${a}`);
|
|
56
|
+
if (opts.input) throw new Error('Only one input file is supported');
|
|
57
|
+
opts.input = a;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!['auto', 'outline', 'headings', 'pages'].includes(opts.split)) {
|
|
61
|
+
throw new Error(`Invalid --split mode: ${opts.split}`);
|
|
62
|
+
}
|
|
63
|
+
return opts;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function usage() {
|
|
67
|
+
return `pdf2epub - Convert a PDF into a reflowable EPUB 3 ebook
|
|
68
|
+
|
|
69
|
+
Usage:
|
|
70
|
+
node pdf2epub.js input.pdf [-o output.epub] [options]
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
-o, --output <file> Output path (default: same name with .epub)
|
|
74
|
+
-t, --title <text> Override book title
|
|
75
|
+
-a, --author <text> Override author
|
|
76
|
+
-l, --lang <code> Language code (default: en)
|
|
77
|
+
-c, --cover <image> Cover image (jpg/png) to embed
|
|
78
|
+
--split <mode> auto | outline | headings | pages (default: auto)
|
|
79
|
+
--pages-per-chapter <n> Pages per chapter in "pages" mode (default: 10)
|
|
80
|
+
--keep-headers Do not strip repeating page headers/footers
|
|
81
|
+
--font-scale <n> Heading size threshold vs body font (default: 1.15)
|
|
82
|
+
-h, --help Show this help
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// PDF text extraction
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Turn pdf.js text items into ordered lines with position + font size.
|
|
92
|
+
* Returns [{ text, x, y, width, size, pageHeight, pageWidth }]
|
|
93
|
+
*/
|
|
94
|
+
function buildLines(textContent, viewport) {
|
|
95
|
+
const items = textContent.items
|
|
96
|
+
.filter((it) => typeof it.str === 'string' && it.str.trim().length > 0)
|
|
97
|
+
.map((it) => {
|
|
98
|
+
const [a, b, , , e, f] = it.transform;
|
|
99
|
+
const size = Math.hypot(a, b) || it.height || 10;
|
|
100
|
+
return { str: it.str, x: e, y: f, w: it.width, size };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Sort top-to-bottom (PDF y grows upwards), then left-to-right.
|
|
104
|
+
items.sort((p, q) => (Math.abs(q.y - p.y) > 2 ? q.y - p.y : p.x - q.x));
|
|
105
|
+
|
|
106
|
+
const lines = [];
|
|
107
|
+
for (const it of items) {
|
|
108
|
+
const last = lines[lines.length - 1];
|
|
109
|
+
if (last && Math.abs(last.y - it.y) <= Math.max(2, last.size * 0.45)) {
|
|
110
|
+
// Same line: add a space if there is a visible horizontal gap.
|
|
111
|
+
const gap = it.x - (last.x + last.width);
|
|
112
|
+
const needSpace = gap > last.size * 0.15 && !last.text.endsWith(' ') && !it.str.startsWith(' ');
|
|
113
|
+
last.text += (needSpace ? ' ' : '') + it.str;
|
|
114
|
+
last.width = Math.max(last.width, it.x + it.w - last.x);
|
|
115
|
+
last.size = Math.max(last.size, it.size);
|
|
116
|
+
} else {
|
|
117
|
+
lines.push({
|
|
118
|
+
text: it.str, x: it.x, y: it.y, width: it.w, size: it.size,
|
|
119
|
+
pageHeight: viewport.height, pageWidth: viewport.width,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const l of lines) l.text = l.text.replace(/\s+/g, ' ').trim();
|
|
124
|
+
return lines.filter((l) => l.text.length > 0);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function median(nums) {
|
|
128
|
+
if (!nums.length) return 0;
|
|
129
|
+
const s = [...nums].sort((a, b) => a - b);
|
|
130
|
+
const mid = Math.floor(s.length / 2);
|
|
131
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Identify header/footer lines that repeat across many pages (page numbers,
|
|
136
|
+
* running titles). Returns a Set of normalized signatures to drop.
|
|
137
|
+
*/
|
|
138
|
+
function detectRepeatingLines(pages) {
|
|
139
|
+
const counts = new Map();
|
|
140
|
+
for (const lines of pages) {
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
for (const l of lines) {
|
|
143
|
+
const top = l.y > l.pageHeight * 0.9;
|
|
144
|
+
const bottom = l.y < l.pageHeight * 0.1;
|
|
145
|
+
if (!top && !bottom) continue;
|
|
146
|
+
const sig = l.text.replace(/\d+/g, '#').toLowerCase();
|
|
147
|
+
if (seen.has(sig)) continue;
|
|
148
|
+
seen.add(sig);
|
|
149
|
+
counts.set(sig, (counts.get(sig) || 0) + 1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const threshold = Math.max(3, pages.length * 0.3);
|
|
153
|
+
const drop = new Set();
|
|
154
|
+
for (const [sig, n] of counts) if (n >= threshold) drop.add(sig);
|
|
155
|
+
return drop;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isPageNumberOnly(text) {
|
|
159
|
+
return /^(page\s+)?[\divxlc]+(\s*(of|\/)\s*\d+)?$/i.test(text.trim());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Convert a page's lines into blocks: { type: 'h'|'p', text, level }.
|
|
164
|
+
*/
|
|
165
|
+
function linesToBlocks(lines, ctx) {
|
|
166
|
+
const blocks = [];
|
|
167
|
+
let para = [];
|
|
168
|
+
const flush = () => {
|
|
169
|
+
if (para.length) {
|
|
170
|
+
blocks.push({ type: 'p', text: joinLines(para) });
|
|
171
|
+
para = [];
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < lines.length; i++) {
|
|
176
|
+
const l = lines[i];
|
|
177
|
+
const prev = lines[i - 1];
|
|
178
|
+
const isHeading =
|
|
179
|
+
l.size >= ctx.bodySize * ctx.fontScale &&
|
|
180
|
+
l.text.length <= 120 &&
|
|
181
|
+
!/[.,;:]$/.test(l.text);
|
|
182
|
+
|
|
183
|
+
if (isHeading) {
|
|
184
|
+
flush();
|
|
185
|
+
// Merge consecutive heading lines with same size (wrapped titles).
|
|
186
|
+
const last = blocks[blocks.length - 1];
|
|
187
|
+
if (last && last.type === 'h' && Math.abs(last.size - l.size) < 0.5 && prev && Math.abs(prev.y - l.y) < l.size * 1.8) {
|
|
188
|
+
last.text += ' ' + l.text;
|
|
189
|
+
} else {
|
|
190
|
+
const level = l.size >= ctx.bodySize * 1.6 ? 1 : 2;
|
|
191
|
+
blocks.push({ type: 'h', text: l.text, level, size: l.size });
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (prev && para.length) {
|
|
197
|
+
const gap = prev.y - l.y;
|
|
198
|
+
const bigGap = gap > l.size * 1.7;
|
|
199
|
+
const indented = l.x - prev.x > l.size * 1.2 && ctx.leftMargin != null && l.x > ctx.leftMargin + l.size;
|
|
200
|
+
const prevShort = prev.width < ctx.textWidth * 0.6 && /[.!?:"”’)\]]$/.test(prev.text);
|
|
201
|
+
if (bigGap || indented || prevShort) flush();
|
|
202
|
+
}
|
|
203
|
+
para.push(l.text);
|
|
204
|
+
}
|
|
205
|
+
flush();
|
|
206
|
+
return blocks;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Join wrapped lines, undoing end-of-line hyphenation where it makes sense. */
|
|
210
|
+
function joinLines(parts) {
|
|
211
|
+
let out = '';
|
|
212
|
+
for (const p of parts) {
|
|
213
|
+
if (!out) { out = p; continue; }
|
|
214
|
+
if (/[A-Za-z]-$/.test(out) && /^[a-z]/.test(p)) {
|
|
215
|
+
out = out.slice(0, -1) + p;
|
|
216
|
+
} else {
|
|
217
|
+
out += ' ' + p;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function extractOutline(pdf) {
|
|
224
|
+
let outline;
|
|
225
|
+
try { outline = await pdf.getOutline(); } catch { return []; }
|
|
226
|
+
if (!outline || !outline.length) return [];
|
|
227
|
+
|
|
228
|
+
const entries = [];
|
|
229
|
+
const walk = async (items, depth) => {
|
|
230
|
+
for (const it of items) {
|
|
231
|
+
let pageIndex = null;
|
|
232
|
+
try {
|
|
233
|
+
let dest = it.dest;
|
|
234
|
+
if (typeof dest === 'string') dest = await pdf.getDestination(dest);
|
|
235
|
+
if (Array.isArray(dest) && dest[0]) {
|
|
236
|
+
pageIndex = await pdf.getPageIndex(dest[0]);
|
|
237
|
+
}
|
|
238
|
+
} catch { /* unresolvable destination */ }
|
|
239
|
+
if (pageIndex != null && it.title) {
|
|
240
|
+
entries.push({ title: it.title.trim(), page: pageIndex, depth });
|
|
241
|
+
}
|
|
242
|
+
if (it.items && it.items.length && depth < 2) await walk(it.items, depth + 1);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
await walk(outline, 0);
|
|
246
|
+
entries.sort((a, b) => a.page - b.page || a.depth - b.depth);
|
|
247
|
+
return entries;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function extractPdf(file, opts) {
|
|
251
|
+
const data = new Uint8Array(await fs.readFile(file));
|
|
252
|
+
const pdf = await getDocument({ data, useSystemFonts: true, disableFontFace: true }).promise;
|
|
253
|
+
|
|
254
|
+
let meta = {};
|
|
255
|
+
try { meta = (await pdf.getMetadata()).info || {}; } catch { /* ignore */ }
|
|
256
|
+
|
|
257
|
+
const rawPages = [];
|
|
258
|
+
const allSizes = [];
|
|
259
|
+
const allLeft = [];
|
|
260
|
+
const allWidths = [];
|
|
261
|
+
|
|
262
|
+
for (let n = 1; n <= pdf.numPages; n++) {
|
|
263
|
+
const page = await pdf.getPage(n);
|
|
264
|
+
const viewport = page.getViewport({ scale: 1 });
|
|
265
|
+
const tc = await page.getTextContent();
|
|
266
|
+
const lines = buildLines(tc, viewport);
|
|
267
|
+
rawPages.push(lines);
|
|
268
|
+
for (const l of lines) {
|
|
269
|
+
allSizes.push(l.size);
|
|
270
|
+
allLeft.push(l.x);
|
|
271
|
+
allWidths.push(l.width);
|
|
272
|
+
}
|
|
273
|
+
page.cleanup();
|
|
274
|
+
if (n % 25 === 0 || n === pdf.numPages) {
|
|
275
|
+
process.stderr.write(`\r extracted ${n}/${pdf.numPages} pages`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
process.stderr.write('\n');
|
|
279
|
+
|
|
280
|
+
const drop = opts.keepHeaders ? new Set() : detectRepeatingLines(rawPages);
|
|
281
|
+
const ctx = {
|
|
282
|
+
bodySize: median(allSizes) || 10,
|
|
283
|
+
leftMargin: median(allLeft),
|
|
284
|
+
textWidth: Math.max(...allWidths, 1),
|
|
285
|
+
fontScale: opts.fontScale,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const pages = rawPages.map((lines) => {
|
|
289
|
+
const kept = lines.filter((l) => {
|
|
290
|
+
const edge = l.y > l.pageHeight * 0.9 || l.y < l.pageHeight * 0.1;
|
|
291
|
+
if (!edge) return true;
|
|
292
|
+
if (opts.keepHeaders) return true;
|
|
293
|
+
if (isPageNumberOnly(l.text)) return false;
|
|
294
|
+
return !drop.has(l.text.replace(/\d+/g, '#').toLowerCase());
|
|
295
|
+
});
|
|
296
|
+
return linesToBlocks(kept, ctx);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const outline = await extractOutline(pdf);
|
|
300
|
+
const result = {
|
|
301
|
+
numPages: pdf.numPages,
|
|
302
|
+
title: meta.Title && meta.Title.trim(),
|
|
303
|
+
author: meta.Author && meta.Author.trim(),
|
|
304
|
+
pages, outline,
|
|
305
|
+
};
|
|
306
|
+
await pdf.destroy();
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Chapter assembly
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
function splitChapters(doc, opts) {
|
|
315
|
+
let mode = opts.split;
|
|
316
|
+
const hasOutline = doc.outline.length >= 2;
|
|
317
|
+
const headingCount = doc.pages.flat().filter((b) => b.type === 'h' && b.level === 1).length;
|
|
318
|
+
if (mode === 'auto') {
|
|
319
|
+
if (hasOutline) mode = 'outline';
|
|
320
|
+
else if (headingCount >= 2 && headingCount <= doc.numPages) mode = 'headings';
|
|
321
|
+
else mode = 'pages';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const chapters = [];
|
|
325
|
+
if (mode === 'outline' && hasOutline) {
|
|
326
|
+
const top = doc.outline.filter((e) => e.depth === 0);
|
|
327
|
+
const points = (top.length >= 2 ? top : doc.outline).filter((e, i, arr) => i === 0 || e.page !== arr[i - 1].page);
|
|
328
|
+
if (points[0].page > 0) {
|
|
329
|
+
chapters.push({ title: 'Front Matter', blocks: doc.pages.slice(0, points[0].page).flat() });
|
|
330
|
+
}
|
|
331
|
+
for (let i = 0; i < points.length; i++) {
|
|
332
|
+
const start = points[i].page;
|
|
333
|
+
const end = i + 1 < points.length ? points[i + 1].page : doc.numPages;
|
|
334
|
+
chapters.push({ title: points[i].title, blocks: doc.pages.slice(start, end).flat() });
|
|
335
|
+
}
|
|
336
|
+
} else if (mode === 'headings' && headingCount >= 1) {
|
|
337
|
+
let cur = { title: 'Front Matter', blocks: [] };
|
|
338
|
+
for (const block of doc.pages.flat()) {
|
|
339
|
+
if (block.type === 'h' && block.level === 1) {
|
|
340
|
+
if (cur.blocks.length) chapters.push(cur);
|
|
341
|
+
cur = { title: block.text, blocks: [block] };
|
|
342
|
+
} else {
|
|
343
|
+
cur.blocks.push(block);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (cur.blocks.length) chapters.push(cur);
|
|
347
|
+
} else {
|
|
348
|
+
mode = 'pages';
|
|
349
|
+
const per = opts.pagesPerChapter;
|
|
350
|
+
for (let i = 0; i < doc.numPages; i += per) {
|
|
351
|
+
const end = Math.min(i + per, doc.numPages);
|
|
352
|
+
const title = doc.numPages <= per ? 'Content' : `Pages ${i + 1}–${end}`;
|
|
353
|
+
chapters.push({ title, blocks: doc.pages.slice(i, end).flat() });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Drop empty chapters (e.g. blank or image-only pages).
|
|
358
|
+
const nonEmpty = chapters.filter((c) => c.blocks.some((b) => b.text.trim()));
|
|
359
|
+
return { mode, chapters: nonEmpty.length ? nonEmpty : [{ title: 'Content', blocks: [] }] };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// EPUB writer
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
function esc(s) {
|
|
367
|
+
return String(s)
|
|
368
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
369
|
+
.replace(/"/g, '"').replace(/'/g, ''')
|
|
370
|
+
// Strip control chars that are illegal in XML 1.0.
|
|
371
|
+
.replace(/[--]/g, '');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function chapterXhtml(chapter, lang) {
|
|
375
|
+
const body = chapter.blocks.map((b) => {
|
|
376
|
+
if (b.type === 'h') return `<h${b.level}>${esc(b.text)}</h${b.level}>`;
|
|
377
|
+
return `<p>${esc(b.text)}</p>`;
|
|
378
|
+
}).join('\n');
|
|
379
|
+
const first = chapter.blocks[0];
|
|
380
|
+
const needsTitle = !(first && first.type === 'h' && first.text === chapter.title);
|
|
381
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
382
|
+
<!DOCTYPE html>
|
|
383
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${esc(lang)}" lang="${esc(lang)}">
|
|
384
|
+
<head>
|
|
385
|
+
<meta charset="utf-8"/>
|
|
386
|
+
<title>${esc(chapter.title)}</title>
|
|
387
|
+
<link rel="stylesheet" type="text/css" href="style.css"/>
|
|
388
|
+
</head>
|
|
389
|
+
<body>
|
|
390
|
+
<section epub:type="chapter">
|
|
391
|
+
${needsTitle ? `<h1>${esc(chapter.title)}</h1>\n` : ''}${body || '<p></p>'}
|
|
392
|
+
</section>
|
|
393
|
+
</body>
|
|
394
|
+
</html>
|
|
395
|
+
`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const STYLE_CSS = `body { margin: 0; padding: 0 1em; font-family: serif; line-height: 1.5; }
|
|
399
|
+
h1, h2, h3 { font-family: sans-serif; line-height: 1.2; margin: 1.5em 0 0.7em; page-break-after: avoid; }
|
|
400
|
+
h1 { font-size: 1.6em; }
|
|
401
|
+
h2 { font-size: 1.3em; }
|
|
402
|
+
h3 { font-size: 1.1em; }
|
|
403
|
+
p { margin: 0 0 0.8em; text-align: justify; text-indent: 0; }
|
|
404
|
+
.cover { text-align: center; margin: 0; padding: 0; }
|
|
405
|
+
.cover img { max-width: 100%; max-height: 100%; }
|
|
406
|
+
`;
|
|
407
|
+
|
|
408
|
+
async function writeEpub(outFile, book) {
|
|
409
|
+
const zip = new JSZip();
|
|
410
|
+
const uuid = randomUUID();
|
|
411
|
+
const modified = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
412
|
+
|
|
413
|
+
// mimetype must be first and uncompressed.
|
|
414
|
+
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
|
|
415
|
+
zip.file('META-INF/container.xml', `<?xml version="1.0" encoding="UTF-8"?>
|
|
416
|
+
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
|
417
|
+
<rootfiles>
|
|
418
|
+
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
|
419
|
+
</rootfiles>
|
|
420
|
+
</container>
|
|
421
|
+
`);
|
|
422
|
+
|
|
423
|
+
const oebps = zip.folder('OEBPS');
|
|
424
|
+
oebps.file('style.css', STYLE_CSS);
|
|
425
|
+
|
|
426
|
+
const manifest = [];
|
|
427
|
+
const spine = [];
|
|
428
|
+
const navItems = [];
|
|
429
|
+
|
|
430
|
+
// Optional cover
|
|
431
|
+
let coverMeta = '';
|
|
432
|
+
if (book.cover) {
|
|
433
|
+
const ext = path.extname(book.cover.file).toLowerCase();
|
|
434
|
+
const mime = ext === '.png' ? 'image/png' : 'image/jpeg';
|
|
435
|
+
const imgName = `cover${ext === '.png' ? '.png' : '.jpg'}`;
|
|
436
|
+
oebps.file(imgName, book.cover.data);
|
|
437
|
+
manifest.push(`<item id="cover-image" href="${imgName}" media-type="${mime}" properties="cover-image"/>`);
|
|
438
|
+
oebps.file('cover.xhtml', `<?xml version="1.0" encoding="UTF-8"?>
|
|
439
|
+
<!DOCTYPE html>
|
|
440
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="${esc(book.lang)}">
|
|
441
|
+
<head><meta charset="utf-8"/><title>Cover</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
|
|
442
|
+
<body class="cover"><img src="${imgName}" alt="Cover"/></body>
|
|
443
|
+
</html>
|
|
444
|
+
`);
|
|
445
|
+
manifest.push(`<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`);
|
|
446
|
+
spine.push(`<itemref idref="cover" linear="no"/>`);
|
|
447
|
+
coverMeta = `<meta name="cover" content="cover-image"/>`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
book.chapters.forEach((ch, i) => {
|
|
451
|
+
const id = `ch${String(i + 1).padStart(3, '0')}`;
|
|
452
|
+
const href = `${id}.xhtml`;
|
|
453
|
+
oebps.file(href, chapterXhtml(ch, book.lang));
|
|
454
|
+
manifest.push(`<item id="${id}" href="${href}" media-type="application/xhtml+xml"/>`);
|
|
455
|
+
spine.push(`<itemref idref="${id}"/>`);
|
|
456
|
+
navItems.push({ id, href, title: ch.title });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
oebps.file('nav.xhtml', `<?xml version="1.0" encoding="UTF-8"?>
|
|
460
|
+
<!DOCTYPE html>
|
|
461
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${esc(book.lang)}">
|
|
462
|
+
<head><meta charset="utf-8"/><title>Table of Contents</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
|
|
463
|
+
<body>
|
|
464
|
+
<nav epub:type="toc" id="toc">
|
|
465
|
+
<h1>Table of Contents</h1>
|
|
466
|
+
<ol>
|
|
467
|
+
${navItems.map((n) => ` <li><a href="${n.href}">${esc(n.title)}</a></li>`).join('\n')}
|
|
468
|
+
</ol>
|
|
469
|
+
</nav>
|
|
470
|
+
</body>
|
|
471
|
+
</html>
|
|
472
|
+
`);
|
|
473
|
+
|
|
474
|
+
// NCX for older readers (EPUB 2 compatibility).
|
|
475
|
+
oebps.file('toc.ncx', `<?xml version="1.0" encoding="UTF-8"?>
|
|
476
|
+
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
|
477
|
+
<head>
|
|
478
|
+
<meta name="dtb:uid" content="urn:uuid:${uuid}"/>
|
|
479
|
+
<meta name="dtb:depth" content="1"/>
|
|
480
|
+
<meta name="dtb:totalPageCount" content="0"/>
|
|
481
|
+
<meta name="dtb:maxPageNumber" content="0"/>
|
|
482
|
+
</head>
|
|
483
|
+
<docTitle><text>${esc(book.title)}</text></docTitle>
|
|
484
|
+
<navMap>
|
|
485
|
+
${navItems.map((n, i) => ` <navPoint id="np${i + 1}" playOrder="${i + 1}"><navLabel><text>${esc(n.title)}</text></navLabel><content src="${n.href}"/></navPoint>`).join('\n')}
|
|
486
|
+
</navMap>
|
|
487
|
+
</ncx>
|
|
488
|
+
`);
|
|
489
|
+
|
|
490
|
+
oebps.file('content.opf', `<?xml version="1.0" encoding="UTF-8"?>
|
|
491
|
+
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid" xml:lang="${esc(book.lang)}">
|
|
492
|
+
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
493
|
+
<dc:identifier id="bookid">urn:uuid:${uuid}</dc:identifier>
|
|
494
|
+
<dc:title>${esc(book.title)}</dc:title>
|
|
495
|
+
<dc:creator>${esc(book.author)}</dc:creator>
|
|
496
|
+
<dc:language>${esc(book.lang)}</dc:language>
|
|
497
|
+
<meta property="dcterms:modified">${modified}</meta>
|
|
498
|
+
${coverMeta}
|
|
499
|
+
</metadata>
|
|
500
|
+
<manifest>
|
|
501
|
+
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
|
502
|
+
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
|
503
|
+
<item id="css" href="style.css" media-type="text/css"/>
|
|
504
|
+
${manifest.join('\n ')}
|
|
505
|
+
</manifest>
|
|
506
|
+
<spine toc="ncx">
|
|
507
|
+
${spine.join('\n ')}
|
|
508
|
+
</spine>
|
|
509
|
+
</package>
|
|
510
|
+
`);
|
|
511
|
+
|
|
512
|
+
const buf = await zip.generateAsync({
|
|
513
|
+
type: 'nodebuffer',
|
|
514
|
+
compression: 'DEFLATE',
|
|
515
|
+
compressionOptions: { level: 9 },
|
|
516
|
+
});
|
|
517
|
+
await fs.writeFile(outFile, buf);
|
|
518
|
+
return buf.length;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Telemetry (opt-in, anonymous). Off unless PDF2EPUB_TELEMETRY=1.
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
const TELEMETRY_URL = 'https://poc.n4b.top/check-ai';
|
|
526
|
+
|
|
527
|
+
// Env vars that AI coding agents set in the shells they spawn.
|
|
528
|
+
const AGENT_ENV = [
|
|
529
|
+
['CLAUDECODE', 'claude-code'],
|
|
530
|
+
['CURSOR_AGENT', 'cursor'],
|
|
531
|
+
['CURSOR_TRACE_ID', 'cursor'],
|
|
532
|
+
['GEMINI_CLI', 'gemini-cli'],
|
|
533
|
+
['CODEX_SANDBOX', 'codex'],
|
|
534
|
+
['CODEX_SANDBOX_NETWORK_DISABLED', 'codex'],
|
|
535
|
+
['OPENCODE', 'opencode'],
|
|
536
|
+
['REPL_ID', 'replit'],
|
|
537
|
+
];
|
|
538
|
+
|
|
539
|
+
function detectAgent(env = process.env) {
|
|
540
|
+
if (env.AI_AGENT) return env.AI_AGENT; // generic convention some agents set
|
|
541
|
+
for (const [key, name] of AGENT_ENV) if (env[key]) return name;
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function reportUsage() {
|
|
546
|
+
// Opt-in only: silent unless the user explicitly turns it on.
|
|
547
|
+
if (process.env.PDF2EPUB_TELEMETRY !== '1' || typeof fetch !== 'function') return;
|
|
548
|
+
console.error('pdf2epub: sending one anonymous usage ping (agent/node/os only). Unset PDF2EPUB_TELEMETRY to disable.');
|
|
549
|
+
fetch(TELEMETRY_URL, {
|
|
550
|
+
method: 'POST',
|
|
551
|
+
headers: { 'content-type': 'application/json' },
|
|
552
|
+
body: JSON.stringify({
|
|
553
|
+
v: '0.1.3',
|
|
554
|
+
agent: detectAgent() ?? 'none',
|
|
555
|
+
node: process.versions.node,
|
|
556
|
+
os: process.platform,
|
|
557
|
+
}),
|
|
558
|
+
signal: AbortSignal.timeout(1500),
|
|
559
|
+
}).catch(() => {});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Main
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
async function main() {
|
|
567
|
+
reportUsage();
|
|
568
|
+
let opts;
|
|
569
|
+
try {
|
|
570
|
+
opts = parseArgs(process.argv.slice(2));
|
|
571
|
+
} catch (e) {
|
|
572
|
+
console.error(`Error: ${e.message}\n`);
|
|
573
|
+
console.error(usage());
|
|
574
|
+
process.exit(2);
|
|
575
|
+
}
|
|
576
|
+
if (opts.help || !opts.input) {
|
|
577
|
+
console.log(usage());
|
|
578
|
+
process.exit(opts.help ? 0 : 2);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const input = path.resolve(opts.input);
|
|
582
|
+
await fs.access(input).catch(() => {
|
|
583
|
+
console.error(`Error: cannot read ${input}`);
|
|
584
|
+
process.exit(1);
|
|
585
|
+
});
|
|
586
|
+
const output = path.resolve(opts.output || input.replace(/\.pdf$/i, '') + '.epub');
|
|
587
|
+
|
|
588
|
+
console.error(`Reading ${path.basename(input)} ...`);
|
|
589
|
+
const doc = await extractPdf(input, opts);
|
|
590
|
+
|
|
591
|
+
const totalChars = doc.pages.flat().reduce((n, b) => n + b.text.length, 0);
|
|
592
|
+
if (totalChars === 0) {
|
|
593
|
+
console.error('Warning: no text layer found. This PDF is probably scanned images; run OCR first (e.g. ocrmypdf).');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const { mode, chapters } = splitChapters(doc, opts);
|
|
597
|
+
|
|
598
|
+
let cover = null;
|
|
599
|
+
if (opts.cover) {
|
|
600
|
+
const file = path.resolve(opts.cover);
|
|
601
|
+
cover = { file, data: await fs.readFile(file) };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const book = {
|
|
605
|
+
title: opts.title || doc.title || path.basename(input, path.extname(input)),
|
|
606
|
+
author: opts.author || doc.author || 'Unknown',
|
|
607
|
+
lang: opts.lang,
|
|
608
|
+
chapters, cover,
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const bytes = await writeEpub(output, book);
|
|
612
|
+
|
|
613
|
+
console.error(`\nTitle: ${book.title}`);
|
|
614
|
+
console.error(`Author: ${book.author}`);
|
|
615
|
+
console.error(`Pages: ${doc.numPages}`);
|
|
616
|
+
console.error(`Chapters: ${chapters.length} (split mode: ${mode})`);
|
|
617
|
+
console.error(`Output: ${output} (${(bytes / 1024).toFixed(1)} KB)`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
main().catch((e) => {
|
|
621
|
+
console.error(`Error: ${e.message}`);
|
|
622
|
+
if (process.env.DEBUG) console.error(e.stack);
|
|
623
|
+
process.exit(1);
|
|
624
|
+
});
|
package/LICENSE
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 dalt4sec (npm wrapper)
|
|
4
|
-
Copyright (c) 2024-2026 overcuriousity (pdf2epub, vendored under vendor/pdf2epub, see its LICENSE)
|
|
5
|
-
|
|
6
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
-
in the Software without restriction, including without limitation the rights
|
|
9
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
-
furnished to do so, subject to the following conditions:
|
|
12
|
-
|
|
13
|
-
The above copyright notice and this permission notice shall be included in all
|
|
14
|
-
copies or substantial portions of the Software.
|
|
15
|
-
|
|
16
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
-
SOFTWARE.
|
package/bin/pdf2epub.js
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const { main } = require('../lib/cli');
|
|
5
|
-
|
|
6
|
-
main(process.argv.slice(2)).then(
|
|
7
|
-
(code) => {
|
|
8
|
-
process.exitCode = code;
|
|
9
|
-
},
|
|
10
|
-
(err) => {
|
|
11
|
-
process.stderr.write(`pdf2epub: ${err && err.message ? err.message : err}\n`);
|
|
12
|
-
process.exitCode = 1;
|
|
13
|
-
}
|
|
14
|
-
);
|