pdf2epub 0.1.1 → 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 -110
- 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
|
@@ -1,696 +0,0 @@
|
|
|
1
|
-
import markdown
|
|
2
|
-
import os
|
|
3
|
-
from xml.dom import minidom
|
|
4
|
-
import zipfile
|
|
5
|
-
import sys
|
|
6
|
-
import json
|
|
7
|
-
from PIL import Image
|
|
8
|
-
import regex as re
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from datetime import datetime, timezone
|
|
11
|
-
import subprocess
|
|
12
|
-
from typing import Dict, Optional
|
|
13
|
-
from urllib.parse import quote
|
|
14
|
-
from xml.sax.saxutils import escape as xml_escape
|
|
15
|
-
import latex2mathml.converter
|
|
16
|
-
|
|
17
|
-
def get_user_input(prompt: str, default: str = "") -> str:
|
|
18
|
-
"""Get user input with a default value."""
|
|
19
|
-
user_input = input(f"{prompt} [{default}]: ").strip()
|
|
20
|
-
return user_input if user_input else default
|
|
21
|
-
|
|
22
|
-
def get_metadata_from_user(existing_metadata: Optional[Dict] = None) -> Dict:
|
|
23
|
-
"""Interactively collect metadata from user with defaults from existing metadata."""
|
|
24
|
-
if existing_metadata is None:
|
|
25
|
-
existing_metadata = {}
|
|
26
|
-
|
|
27
|
-
metadata = existing_metadata.get("metadata", {})
|
|
28
|
-
|
|
29
|
-
print("\nPlease provide the following metadata for your EPUB (press Enter to use default value):")
|
|
30
|
-
|
|
31
|
-
fields = {
|
|
32
|
-
"dc:title": ("Title", metadata.get("dc:title", "Untitled Document")),
|
|
33
|
-
"dc:creator": ("Author(s)", metadata.get("dc:creator", "Unknown Author")),
|
|
34
|
-
"dc:identifier": ("Unique Identifier", metadata.get("dc:identifier", f"id-{datetime.now().strftime('%Y%m%d%H%M%S')}")),
|
|
35
|
-
"dc:language": ("Language (e.g., en, de, fr)", metadata.get("dc:language", "en")),
|
|
36
|
-
"dc:rights": ("Rights", metadata.get("dc:rights", "All rights reserved")),
|
|
37
|
-
"dc:publisher": ("Publisher", metadata.get("dc:publisher", "PDF2EPUB")),
|
|
38
|
-
"dc:date": ("Publication Date (YYYY-MM-DD)", metadata.get("dc:date", datetime.now().strftime("%Y-%m-%d")))
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
updated_metadata = {}
|
|
42
|
-
for key, (prompt, default) in fields.items():
|
|
43
|
-
value = get_user_input(prompt, default)
|
|
44
|
-
updated_metadata[key] = value
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
"metadata": updated_metadata,
|
|
48
|
-
"default_css": existing_metadata.get("default_css", ["style.css"]),
|
|
49
|
-
"chapters": existing_metadata.get("chapters", []),
|
|
50
|
-
"cover_image": existing_metadata.get("cover_image", None)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
def review_markdown(markdown_path: Path) -> tuple[bool, str]:
|
|
54
|
-
"""Ask user if they want to review the markdown file."""
|
|
55
|
-
content = markdown_path.read_text(encoding='utf-8')
|
|
56
|
-
|
|
57
|
-
while True:
|
|
58
|
-
response = input("\nWould you like to review the markdown file before conversion? (y/n): ").lower()
|
|
59
|
-
if response in ['y', 'yes']:
|
|
60
|
-
try:
|
|
61
|
-
if sys.platform == 'darwin':
|
|
62
|
-
subprocess.run(['open', str(markdown_path)], check=True)
|
|
63
|
-
elif os.name == 'posix':
|
|
64
|
-
subprocess.run(['xdg-open', str(markdown_path)], check=True)
|
|
65
|
-
else:
|
|
66
|
-
os.startfile(str(markdown_path))
|
|
67
|
-
|
|
68
|
-
while True:
|
|
69
|
-
proceed = input("\nPress Enter when you're done editing (or 'q' to abort): ").lower()
|
|
70
|
-
if proceed == 'q':
|
|
71
|
-
return False, content
|
|
72
|
-
elif proceed == '':
|
|
73
|
-
updated_content = markdown_path.read_text(encoding='utf-8')
|
|
74
|
-
return True, updated_content
|
|
75
|
-
except Exception as e:
|
|
76
|
-
print(f"\nError opening markdown file: {e}")
|
|
77
|
-
print("Proceeding with conversion...")
|
|
78
|
-
return True, content
|
|
79
|
-
elif response in ['n', 'no']:
|
|
80
|
-
return True, content
|
|
81
|
-
else:
|
|
82
|
-
print("Please enter 'y' or 'n'")
|
|
83
|
-
|
|
84
|
-
def build_image_lookup(images_dir: Path) -> dict:
|
|
85
|
-
"""Build a {lowercase_name: actual_path} map for O(1) case-insensitive lookups."""
|
|
86
|
-
lookup = {}
|
|
87
|
-
try:
|
|
88
|
-
for entry in images_dir.iterdir():
|
|
89
|
-
lookup[entry.name.lower()] = entry
|
|
90
|
-
except FileNotFoundError:
|
|
91
|
-
pass
|
|
92
|
-
return lookup
|
|
93
|
-
|
|
94
|
-
def process_markdown_for_images(markdown_text: str, work_dir: Path) -> tuple[str, list[str]]:
|
|
95
|
-
"""Process markdown content to find image references."""
|
|
96
|
-
image_pattern = r'!\[(.*?)\]\((.*?)\)'
|
|
97
|
-
images_found = []
|
|
98
|
-
modified_text = markdown_text
|
|
99
|
-
images_dir = work_dir / 'images'
|
|
100
|
-
lookup = build_image_lookup(images_dir)
|
|
101
|
-
|
|
102
|
-
for match in re.finditer(image_pattern, markdown_text):
|
|
103
|
-
alt_text, image_path = match.groups()
|
|
104
|
-
img_path = Path(image_path.strip())
|
|
105
|
-
|
|
106
|
-
actual = lookup.get(img_path.name.lower())
|
|
107
|
-
if actual is not None:
|
|
108
|
-
images_found.append(actual.name)
|
|
109
|
-
new_ref = f''
|
|
110
|
-
modified_text = modified_text.replace(match.group(0), new_ref)
|
|
111
|
-
else:
|
|
112
|
-
print(f"Warning: Image not found: {images_dir / img_path.name}")
|
|
113
|
-
|
|
114
|
-
return modified_text, images_found
|
|
115
|
-
|
|
116
|
-
def copy_and_optimize_image(src_path: Path, dest_path: Path, max_dimension: int = 1800) -> None:
|
|
117
|
-
"""Copy image to destination path with optimization for EPUB."""
|
|
118
|
-
try:
|
|
119
|
-
with Image.open(src_path) as img:
|
|
120
|
-
if img.mode == 'RGBA':
|
|
121
|
-
img = img.convert('RGB')
|
|
122
|
-
|
|
123
|
-
ratio = min(max_dimension / max(img.size[0], img.size[1]), 1.0)
|
|
124
|
-
new_size = tuple(int(dim * ratio) for dim in img.size)
|
|
125
|
-
|
|
126
|
-
if ratio < 1.0:
|
|
127
|
-
img = img.resize(new_size, Image.Resampling.LANCZOS)
|
|
128
|
-
|
|
129
|
-
if src_path.suffix.lower() in ['.jpg', '.jpeg']:
|
|
130
|
-
img.save(dest_path, 'JPEG', quality=85, optimize=True)
|
|
131
|
-
elif src_path.suffix.lower() == '.png':
|
|
132
|
-
img.save(dest_path, 'PNG', optimize=True)
|
|
133
|
-
else:
|
|
134
|
-
dest_path = dest_path.with_suffix('.jpg')
|
|
135
|
-
img.save(dest_path, 'JPEG', quality=85, optimize=True)
|
|
136
|
-
|
|
137
|
-
except Exception as e:
|
|
138
|
-
print(f"Error processing image {src_path}: {e}")
|
|
139
|
-
raise
|
|
140
|
-
|
|
141
|
-
def update_package_manifest(doc: minidom.Document, image_filenames: list[str],
|
|
142
|
-
manifest: minidom.Element) -> None:
|
|
143
|
-
"""
|
|
144
|
-
Update package manifest with image items, ensuring proper media types.
|
|
145
|
-
"""
|
|
146
|
-
for i, image_filename in enumerate(image_filenames):
|
|
147
|
-
item = doc.createElement('item')
|
|
148
|
-
item.setAttribute('id', f"image-{i:05d}")
|
|
149
|
-
item.setAttribute('href', f"images/{image_filename}")
|
|
150
|
-
|
|
151
|
-
# Set appropriate media type based on file extension
|
|
152
|
-
ext = Path(image_filename).suffix.lower()
|
|
153
|
-
if ext in ['.jpg', '.jpeg']:
|
|
154
|
-
media_type = 'image/jpeg'
|
|
155
|
-
elif ext == '.png':
|
|
156
|
-
media_type = 'image/png'
|
|
157
|
-
elif ext == '.gif':
|
|
158
|
-
media_type = 'image/gif'
|
|
159
|
-
else:
|
|
160
|
-
print(f"Warning: Unsupported image type {ext} for {image_filename}")
|
|
161
|
-
continue
|
|
162
|
-
|
|
163
|
-
item.setAttribute('media-type', media_type)
|
|
164
|
-
manifest.appendChild(item)
|
|
165
|
-
|
|
166
|
-
def get_all_filenames(the_dir, extensions=[]):
|
|
167
|
-
if not os.path.exists(the_dir):
|
|
168
|
-
return []
|
|
169
|
-
all_files = [x for x in os.listdir(the_dir)]
|
|
170
|
-
all_files = [x for x in all_files if x.split(".")[-1] in extensions]
|
|
171
|
-
return all_files
|
|
172
|
-
|
|
173
|
-
def get_packageOPF_XML(md_filenames=[], image_filenames=[], css_filenames=[], description_data=None):
|
|
174
|
-
doc = minidom.Document()
|
|
175
|
-
|
|
176
|
-
package = doc.createElement('package')
|
|
177
|
-
package.setAttribute('xmlns',"http://www.idpf.org/2007/opf")
|
|
178
|
-
package.setAttribute('version',"3.0")
|
|
179
|
-
package.setAttribute('xml:lang',"en")
|
|
180
|
-
package.setAttribute("unique-identifier","pub-id")
|
|
181
|
-
|
|
182
|
-
## Now building the metadata
|
|
183
|
-
|
|
184
|
-
metadata = doc.createElement('metadata')
|
|
185
|
-
metadata.setAttribute('xmlns:dc', 'http://purl.org/dc/elements/1.1/')
|
|
186
|
-
|
|
187
|
-
for k,v in description_data["metadata"].items():
|
|
188
|
-
if len(v):
|
|
189
|
-
x = doc.createElement(k)
|
|
190
|
-
for metadata_type,id_label in [("dc:title","title"),("dc:creator","creator"),("dc:identifier","pub-id")]:
|
|
191
|
-
if k==metadata_type:
|
|
192
|
-
x.setAttribute('id',id_label)
|
|
193
|
-
x.appendChild(doc.createTextNode(v))
|
|
194
|
-
metadata.appendChild(x)
|
|
195
|
-
|
|
196
|
-
# Required by EPUB 3: dcterms:modified timestamp
|
|
197
|
-
modified_meta = doc.createElement('meta')
|
|
198
|
-
modified_meta.setAttribute('property', 'dcterms:modified')
|
|
199
|
-
modified_meta.appendChild(doc.createTextNode(datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")))
|
|
200
|
-
metadata.appendChild(modified_meta)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
## Now building the manifest
|
|
204
|
-
|
|
205
|
-
manifest = doc.createElement('manifest')
|
|
206
|
-
|
|
207
|
-
## TOC.xhtml file for EPUB 3
|
|
208
|
-
x = doc.createElement('item')
|
|
209
|
-
x.setAttribute('id',"toc")
|
|
210
|
-
x.setAttribute('properties',"nav")
|
|
211
|
-
x.setAttribute('href',"TOC.xhtml")
|
|
212
|
-
x.setAttribute('media-type',"application/xhtml+xml")
|
|
213
|
-
manifest.appendChild(x)
|
|
214
|
-
|
|
215
|
-
## Ensure retrocompatibility by also providing a TOC.ncx file
|
|
216
|
-
x = doc.createElement('item')
|
|
217
|
-
x.setAttribute('id',"ncx")
|
|
218
|
-
x.setAttribute('href',"toc.ncx")
|
|
219
|
-
x.setAttribute('media-type',"application/x-dtbncx+xml")
|
|
220
|
-
manifest.appendChild(x)
|
|
221
|
-
|
|
222
|
-
x = doc.createElement('item')
|
|
223
|
-
x.setAttribute('id',"titlepage")
|
|
224
|
-
x.setAttribute('href',"titlepage.xhtml")
|
|
225
|
-
x.setAttribute('media-type',"application/xhtml+xml")
|
|
226
|
-
manifest.appendChild(x)
|
|
227
|
-
|
|
228
|
-
for i,md_filename in enumerate(md_filenames):
|
|
229
|
-
x = doc.createElement('item')
|
|
230
|
-
x.setAttribute('id',"s{:05d}".format(i))
|
|
231
|
-
x.setAttribute('href', quote("s{:05d}-{}.xhtml".format(i, md_filename.split(".")[0])))
|
|
232
|
-
x.setAttribute('media-type',"application/xhtml+xml")
|
|
233
|
-
manifest.appendChild(x)
|
|
234
|
-
|
|
235
|
-
for i,image_filename in enumerate(image_filenames):
|
|
236
|
-
x = doc.createElement('item')
|
|
237
|
-
x.setAttribute('id',"image-{:05d}".format(i))
|
|
238
|
-
x.setAttribute('href', "images/{}".format(quote(image_filename)))
|
|
239
|
-
ext = Path(image_filename).suffix.lower()
|
|
240
|
-
if ext == '.gif':
|
|
241
|
-
x.setAttribute('media-type',"image/gif")
|
|
242
|
-
elif ext in ['.jpg', '.jpeg']:
|
|
243
|
-
x.setAttribute('media-type',"image/jpeg")
|
|
244
|
-
elif ext == '.png':
|
|
245
|
-
x.setAttribute('media-type',"image/png")
|
|
246
|
-
if image_filename==description_data["cover_image"]:
|
|
247
|
-
x.setAttribute('properties',"cover-image")
|
|
248
|
-
|
|
249
|
-
## Ensure compatibility by also providing a meta tag in the metadata
|
|
250
|
-
y = doc.createElement('meta')
|
|
251
|
-
y.setAttribute('name',"cover")
|
|
252
|
-
y.setAttribute('content',"image-{:05d}".format(i))
|
|
253
|
-
metadata.appendChild(y)
|
|
254
|
-
manifest.appendChild(x)
|
|
255
|
-
|
|
256
|
-
for i,css_filename in enumerate(css_filenames):
|
|
257
|
-
x = doc.createElement('item')
|
|
258
|
-
x.setAttribute('id',"css-{:05d}".format(i))
|
|
259
|
-
x.setAttribute('href',"css/{}".format(css_filename))
|
|
260
|
-
x.setAttribute('media-type',"text/css")
|
|
261
|
-
manifest.appendChild(x)
|
|
262
|
-
|
|
263
|
-
## Now building the spine
|
|
264
|
-
|
|
265
|
-
spine = doc.createElement('spine')
|
|
266
|
-
spine.setAttribute('toc', "ncx")
|
|
267
|
-
|
|
268
|
-
x = doc.createElement('itemref')
|
|
269
|
-
x.setAttribute('idref',"titlepage")
|
|
270
|
-
x.setAttribute('linear',"yes")
|
|
271
|
-
spine.appendChild(x)
|
|
272
|
-
for i,_ in enumerate(md_filenames):
|
|
273
|
-
x = doc.createElement('itemref')
|
|
274
|
-
x.setAttribute('idref',"s{:05d}".format(i))
|
|
275
|
-
x.setAttribute('linear',"yes")
|
|
276
|
-
spine.appendChild(x)
|
|
277
|
-
|
|
278
|
-
guide = doc.createElement('guide')
|
|
279
|
-
x = doc.createElement('reference')
|
|
280
|
-
x.setAttribute('type',"cover")
|
|
281
|
-
x.setAttribute('title',"Cover image")
|
|
282
|
-
x.setAttribute('href',"titlepage.xhtml")
|
|
283
|
-
guide.appendChild(x)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
package.appendChild(metadata)
|
|
287
|
-
package.appendChild(manifest)
|
|
288
|
-
package.appendChild(spine)
|
|
289
|
-
package.appendChild(guide)
|
|
290
|
-
doc.appendChild(package)
|
|
291
|
-
|
|
292
|
-
return doc.toprettyxml()
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
def get_container_XML():
|
|
296
|
-
container_data = """<?xml version="1.0" encoding="UTF-8" ?>\n"""
|
|
297
|
-
container_data += """<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n"""
|
|
298
|
-
container_data += """<rootfiles>\n"""
|
|
299
|
-
container_data += """<rootfile full-path="OPS/package.opf" media-type="application/oebps-package+xml"/>\n"""
|
|
300
|
-
container_data += """</rootfiles>\n</container>"""
|
|
301
|
-
return container_data
|
|
302
|
-
|
|
303
|
-
def get_coverpage_XML(title, authors):
|
|
304
|
-
"""Generate a simple cover page with title and optional author input."""
|
|
305
|
-
return f"""<?xml version="1.0" encoding="utf-8"?>
|
|
306
|
-
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
|
|
307
|
-
<head>
|
|
308
|
-
<title>Cover Page</title>
|
|
309
|
-
<style type="text/css">
|
|
310
|
-
body {{
|
|
311
|
-
margin: 0;
|
|
312
|
-
padding: 0;
|
|
313
|
-
height: 100vh;
|
|
314
|
-
display: flex;
|
|
315
|
-
justify-content: center;
|
|
316
|
-
align-items: center;
|
|
317
|
-
font-family: serif;
|
|
318
|
-
}}
|
|
319
|
-
.cover {{
|
|
320
|
-
padding: 3em;
|
|
321
|
-
text-align: center;
|
|
322
|
-
border: 1px solid #ccc;
|
|
323
|
-
max-width: 80%;
|
|
324
|
-
}}
|
|
325
|
-
h1 {{
|
|
326
|
-
font-size: 2em;
|
|
327
|
-
margin-bottom: 1em;
|
|
328
|
-
line-height: 1.2;
|
|
329
|
-
color: #333;
|
|
330
|
-
}}
|
|
331
|
-
p {{
|
|
332
|
-
font-size: 1.2em;
|
|
333
|
-
font-style: italic;
|
|
334
|
-
color: #666;
|
|
335
|
-
line-height: 1.4;
|
|
336
|
-
}}
|
|
337
|
-
</style>
|
|
338
|
-
</head>
|
|
339
|
-
<body>
|
|
340
|
-
<div class="cover">
|
|
341
|
-
<h1>{xml_escape(title)}</h1>
|
|
342
|
-
<p>{xml_escape(authors) if authors else ''}</p>
|
|
343
|
-
</div>
|
|
344
|
-
</body>
|
|
345
|
-
</html>"""
|
|
346
|
-
|
|
347
|
-
def get_TOC_XML(default_css_filenames, markdown_filenames):
|
|
348
|
-
## Returns the XML data for the TOC.xhtml file
|
|
349
|
-
|
|
350
|
-
toc_xhtml = """<?xml version="1.0" encoding="UTF-8"?>\n"""
|
|
351
|
-
toc_xhtml += """<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en">\n"""
|
|
352
|
-
toc_xhtml += """<head>\n<meta http-equiv="default-style" content="text/html; charset=utf-8"/>\n"""
|
|
353
|
-
toc_xhtml += """<title>Contents</title>\n"""
|
|
354
|
-
|
|
355
|
-
for css_filename in default_css_filenames:
|
|
356
|
-
toc_xhtml += """<link rel="stylesheet" href="css/{}" type="text/css"/>\n""".format(css_filename)
|
|
357
|
-
|
|
358
|
-
toc_xhtml += """</head>\n<body>\n"""
|
|
359
|
-
toc_xhtml += """<nav epub:type="toc" role="doc-toc" id="toc">\n<h2>Contents</h2>\n<ol epub:type="list">"""
|
|
360
|
-
for i,md_filename in enumerate(markdown_filenames):
|
|
361
|
-
stem = md_filename.split(".")[0]
|
|
362
|
-
href = quote("s{:05d}-{}.xhtml".format(i, stem))
|
|
363
|
-
toc_xhtml += """<li><a href="{}">{}</a></li>""".format(href, xml_escape(stem))
|
|
364
|
-
toc_xhtml += """</ol>\n</nav>\n</body>\n</html>"""
|
|
365
|
-
|
|
366
|
-
return toc_xhtml
|
|
367
|
-
|
|
368
|
-
def get_TOCNCX_XML(markdown_filenames, uid="", title=""):
|
|
369
|
-
## Returns the XML data for the TOC.ncx file
|
|
370
|
-
|
|
371
|
-
toc_ncx = """<?xml version="1.0" encoding="UTF-8"?>\n"""
|
|
372
|
-
toc_ncx += """<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" xml:lang="en" version="2005-1">\n"""
|
|
373
|
-
toc_ncx += """<head>\n"""
|
|
374
|
-
toc_ncx += """<meta name="dtb:uid" content="{}"/>\n""".format(xml_escape(uid))
|
|
375
|
-
toc_ncx += """<meta name="dtb:depth" content="1"/>\n"""
|
|
376
|
-
toc_ncx += """<meta name="dtb:totalPageCount" content="0"/>\n"""
|
|
377
|
-
toc_ncx += """<meta name="dtb:maxPageNumber" content="0"/>\n"""
|
|
378
|
-
toc_ncx += """</head>\n"""
|
|
379
|
-
toc_ncx += """<docTitle><text>{}</text></docTitle>\n""".format(xml_escape(title))
|
|
380
|
-
toc_ncx += """<navMap>\n"""
|
|
381
|
-
for i,md_filename in enumerate(markdown_filenames):
|
|
382
|
-
stem = md_filename.split(".")[0]
|
|
383
|
-
src = quote("s{:05d}-{}.xhtml".format(i, stem))
|
|
384
|
-
toc_ncx += """<navPoint id="navpoint-{}" playOrder="{}">\n""".format(i, i + 1)
|
|
385
|
-
toc_ncx += """<navLabel>\n<text>{}</text>\n</navLabel>""".format(xml_escape(stem))
|
|
386
|
-
toc_ncx += """<content src="{}"/>""".format(src)
|
|
387
|
-
toc_ncx += """ </navPoint>"""
|
|
388
|
-
toc_ncx += """</navMap>\n</ncx>"""
|
|
389
|
-
|
|
390
|
-
return toc_ncx
|
|
391
|
-
|
|
392
|
-
def convert_math_to_mathml(html_text: str) -> str:
|
|
393
|
-
"""Replace LaTeX math expressions in HTML with MathML, skipping code blocks."""
|
|
394
|
-
# Mask <pre>/<code> blocks so their $ delimiters are never treated as math
|
|
395
|
-
placeholders = {}
|
|
396
|
-
counter = [0]
|
|
397
|
-
|
|
398
|
-
def mask(m):
|
|
399
|
-
key = f"\x00MASK{counter[0]}\x00"
|
|
400
|
-
counter[0] += 1
|
|
401
|
-
placeholders[key] = m.group(0)
|
|
402
|
-
return key
|
|
403
|
-
|
|
404
|
-
masked = re.sub(r'<pre[\s\S]*?</pre>|<code[\s\S]*?</code>', mask, html_text, flags=re.DOTALL)
|
|
405
|
-
|
|
406
|
-
def try_convert(latex):
|
|
407
|
-
try:
|
|
408
|
-
return latex2mathml.converter.convert(latex)
|
|
409
|
-
except Exception:
|
|
410
|
-
return None
|
|
411
|
-
|
|
412
|
-
# Standalone display math that Markdown wrapped in <p>: replace the whole
|
|
413
|
-
# paragraph to avoid invalid XHTML like <p><div>...</div></p>
|
|
414
|
-
def replace_display_paragraph(m):
|
|
415
|
-
mathml = try_convert(m.group(1))
|
|
416
|
-
return f'<div class="math-display">{mathml}</div>' if mathml else m.group(0)
|
|
417
|
-
|
|
418
|
-
# Remaining $$...$$ (inside phrasing content): use <span> to stay valid
|
|
419
|
-
def replace_display_inline(m):
|
|
420
|
-
mathml = try_convert(m.group(1))
|
|
421
|
-
return f'<span class="math-display">{mathml}</span>' if mathml else m.group(0)
|
|
422
|
-
|
|
423
|
-
# Inline $...$
|
|
424
|
-
def replace_inline(m):
|
|
425
|
-
mathml = try_convert(m.group(1))
|
|
426
|
-
return f'<span class="math-inline">{mathml}</span>' if mathml else m.group(0)
|
|
427
|
-
|
|
428
|
-
masked = re.sub(r'<p>\s*\$\$(.*?)\$\$\s*</p>', replace_display_paragraph, masked, flags=re.DOTALL)
|
|
429
|
-
masked = re.sub(r'\$\$(.*?)\$\$', replace_display_inline, masked, flags=re.DOTALL)
|
|
430
|
-
masked = re.sub(r'(?<!\$)\$(?!\$)(.*?)(?<!\$)\$(?!\$)', replace_inline, masked, flags=re.DOTALL)
|
|
431
|
-
|
|
432
|
-
for key, original in placeholders.items():
|
|
433
|
-
masked = masked.replace(key, original)
|
|
434
|
-
|
|
435
|
-
return masked
|
|
436
|
-
|
|
437
|
-
def get_chapter_XML(work_dir: str, md_filename: str, css_filenames: list[str], content: Optional[str] = None) -> tuple[str, list[str]]:
|
|
438
|
-
"""
|
|
439
|
-
Convert markdown chapter to XHTML and process images.
|
|
440
|
-
Returns tuple of (XHTML content, list of images referenced in chapter)
|
|
441
|
-
|
|
442
|
-
Args:
|
|
443
|
-
work_dir: Working directory containing markdown files
|
|
444
|
-
md_filename: Name of markdown file
|
|
445
|
-
css_filenames: List of CSS files to include
|
|
446
|
-
content: Optional pre-loaded markdown content. If None, content is read from file
|
|
447
|
-
"""
|
|
448
|
-
work_dir_path = Path(work_dir)
|
|
449
|
-
|
|
450
|
-
if content is None:
|
|
451
|
-
with open(work_dir_path / md_filename, "r", encoding="utf-8") as f:
|
|
452
|
-
markdown_data = f.read()
|
|
453
|
-
else:
|
|
454
|
-
markdown_data = content
|
|
455
|
-
|
|
456
|
-
# Process markdown for images and get list of referenced images
|
|
457
|
-
markdown_data, chapter_images = process_markdown_for_images(markdown_data, work_dir_path)
|
|
458
|
-
|
|
459
|
-
# Convert to HTML
|
|
460
|
-
html_text = markdown.markdown(
|
|
461
|
-
markdown_data,
|
|
462
|
-
extensions=["codehilite", "tables", "fenced_code", "footnotes"],
|
|
463
|
-
extension_configs={"codehilite": {"guess_lang": False}}
|
|
464
|
-
)
|
|
465
|
-
|
|
466
|
-
# Convert LaTeX math to MathML for EPUB readers
|
|
467
|
-
html_text = convert_math_to_mathml(html_text)
|
|
468
|
-
|
|
469
|
-
# Generate XHTML wrapper
|
|
470
|
-
xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
471
|
-
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xmlns:m="http://www.w3.org/1998/Math/MathML" lang="en">
|
|
472
|
-
<head>
|
|
473
|
-
<meta http-equiv="default-style" content="text/html; charset=utf-8"/>
|
|
474
|
-
{''.join(f'<link rel="stylesheet" href="css/{css}" type="text/css" media="all"/>' for css in css_filenames)}
|
|
475
|
-
</head>
|
|
476
|
-
<body>
|
|
477
|
-
{html_text}
|
|
478
|
-
</body>
|
|
479
|
-
</html>"""
|
|
480
|
-
|
|
481
|
-
return xhtml, chapter_images
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def convert_to_epub(markdown_dir: Path, output_path: Path) -> None:
|
|
486
|
-
"""
|
|
487
|
-
Convert markdown files and images to EPUB format.
|
|
488
|
-
"""
|
|
489
|
-
if not markdown_dir.exists():
|
|
490
|
-
raise FileNotFoundError(f"Markdown directory not found: {markdown_dir}")
|
|
491
|
-
|
|
492
|
-
if not list(markdown_dir.glob('*.md')):
|
|
493
|
-
raise ValueError(f"No markdown files found in: {markdown_dir}")
|
|
494
|
-
|
|
495
|
-
# Generate EPUB file
|
|
496
|
-
epub_path = markdown_dir / f"{markdown_dir.name}.epub"
|
|
497
|
-
main([str(markdown_dir), str(epub_path)])
|
|
498
|
-
|
|
499
|
-
def main(args):
|
|
500
|
-
if len(args) < 2:
|
|
501
|
-
print("\nUsage:\n python md2epub.py <markdown_directory> <output_file.epub>")
|
|
502
|
-
exit(1)
|
|
503
|
-
|
|
504
|
-
work_dir = args[0]
|
|
505
|
-
output_path = args[1]
|
|
506
|
-
|
|
507
|
-
images_dir = os.path.join(work_dir, 'images/')
|
|
508
|
-
css_dir = os.path.join(work_dir, 'css/')
|
|
509
|
-
|
|
510
|
-
try:
|
|
511
|
-
# Reading/Creating the JSON file containing the description of the eBook
|
|
512
|
-
description_path = os.path.join(work_dir, "description.json")
|
|
513
|
-
existing_metadata = {}
|
|
514
|
-
|
|
515
|
-
if os.path.exists(description_path):
|
|
516
|
-
with open(description_path, 'r', encoding='utf-8') as f:
|
|
517
|
-
existing_metadata = json.load(f)
|
|
518
|
-
|
|
519
|
-
# Get metadata from user
|
|
520
|
-
json_data = get_metadata_from_user(existing_metadata)
|
|
521
|
-
|
|
522
|
-
# Find all markdown files if not already in metadata
|
|
523
|
-
if not json_data["chapters"]:
|
|
524
|
-
markdown_files = [f for f in os.listdir(work_dir) if f.endswith('.md')]
|
|
525
|
-
for md_file in sorted(markdown_files):
|
|
526
|
-
json_data["chapters"].append({
|
|
527
|
-
"markdown": md_file,
|
|
528
|
-
"css": ""
|
|
529
|
-
})
|
|
530
|
-
|
|
531
|
-
# Save the updated description.json
|
|
532
|
-
with open(description_path, 'w', encoding='utf-8') as f:
|
|
533
|
-
json.dump(json_data, f, indent=2)
|
|
534
|
-
|
|
535
|
-
# Review markdown files and store updated content
|
|
536
|
-
chapter_contents = {}
|
|
537
|
-
for chapter in json_data["chapters"]:
|
|
538
|
-
md_path = Path(work_dir) / chapter["markdown"]
|
|
539
|
-
should_continue, content = review_markdown(md_path)
|
|
540
|
-
if not should_continue:
|
|
541
|
-
print("\nConversion aborted by user.")
|
|
542
|
-
return
|
|
543
|
-
chapter_contents[chapter["markdown"]] = content
|
|
544
|
-
|
|
545
|
-
# Get title and author
|
|
546
|
-
title = json_data["metadata"].get("dc:title", "Untitled Document")
|
|
547
|
-
authors = json_data["metadata"].get("dc:creator", None)
|
|
548
|
-
|
|
549
|
-
# Compile list of files
|
|
550
|
-
all_md_filenames = []
|
|
551
|
-
all_css_filenames = json_data["default_css"][:]
|
|
552
|
-
for chapter in json_data["chapters"]:
|
|
553
|
-
if chapter["markdown"] not in all_md_filenames:
|
|
554
|
-
all_md_filenames.append(chapter["markdown"])
|
|
555
|
-
if len(chapter["css"]) and (chapter["css"] not in all_css_filenames):
|
|
556
|
-
all_css_filenames.append(chapter["css"])
|
|
557
|
-
|
|
558
|
-
all_image_filenames = get_all_filenames(images_dir, extensions=["gif", "jpg", "jpeg", "png"])
|
|
559
|
-
|
|
560
|
-
# First process all chapters and images
|
|
561
|
-
images_dir = Path(work_dir) / 'images'
|
|
562
|
-
epub_images_dir = Path(work_dir) / 'epub_images'
|
|
563
|
-
processed_images = {} # Store processed image data
|
|
564
|
-
all_referenced_images = set()
|
|
565
|
-
chapter_data = {} # Store processed chapter data
|
|
566
|
-
|
|
567
|
-
# First pass: Process chapters and collect image references
|
|
568
|
-
print("\nProcessing chapters and collecting image references...")
|
|
569
|
-
for i, chapter in enumerate(json_data["chapters"]):
|
|
570
|
-
css_files = json_data["default_css"][:]
|
|
571
|
-
if chapter["css"]:
|
|
572
|
-
css_files.append(chapter["css"])
|
|
573
|
-
|
|
574
|
-
# Process chapter content
|
|
575
|
-
chapter_xhtml, chapter_images = get_chapter_XML(
|
|
576
|
-
work_dir,
|
|
577
|
-
chapter["markdown"],
|
|
578
|
-
css_files,
|
|
579
|
-
content=chapter_contents[chapter["markdown"]]
|
|
580
|
-
)
|
|
581
|
-
chapter_data[chapter["markdown"]] = chapter_xhtml
|
|
582
|
-
all_referenced_images.update(chapter_images)
|
|
583
|
-
|
|
584
|
-
# Process and optimize images
|
|
585
|
-
print("\nProcessing and optimizing images...")
|
|
586
|
-
if images_dir.exists() and all_referenced_images:
|
|
587
|
-
epub_images_dir.mkdir(exist_ok=True)
|
|
588
|
-
|
|
589
|
-
for image in all_referenced_images:
|
|
590
|
-
src_path = images_dir / image
|
|
591
|
-
if src_path.exists():
|
|
592
|
-
try:
|
|
593
|
-
dest_path = epub_images_dir / image
|
|
594
|
-
copy_and_optimize_image(src_path, dest_path)
|
|
595
|
-
|
|
596
|
-
# Store processed image data
|
|
597
|
-
with open(dest_path, "rb") as f:
|
|
598
|
-
processed_images[image] = f.read()
|
|
599
|
-
except Exception as e:
|
|
600
|
-
print(f"Warning: Failed to process image {image}: {e}")
|
|
601
|
-
else:
|
|
602
|
-
print(f"Warning: Referenced image not found: {src_path}")
|
|
603
|
-
|
|
604
|
-
# Cleanup temporary directory
|
|
605
|
-
import shutil
|
|
606
|
-
shutil.rmtree(epub_images_dir, ignore_errors=True)
|
|
607
|
-
|
|
608
|
-
# Now create the EPUB file with all prepared content
|
|
609
|
-
print("\nCreating EPUB file...")
|
|
610
|
-
with zipfile.ZipFile(output_path, "w") as epub:
|
|
611
|
-
# Write mimetype (must be first and uncompressed)
|
|
612
|
-
epub.writestr("mimetype", "application/epub+zip")
|
|
613
|
-
|
|
614
|
-
# Write container.xml
|
|
615
|
-
epub.writestr("META-INF/container.xml", get_container_XML(), zipfile.ZIP_DEFLATED)
|
|
616
|
-
|
|
617
|
-
# Write package.opf
|
|
618
|
-
epub.writestr("OPS/package.opf",
|
|
619
|
-
get_packageOPF_XML(
|
|
620
|
-
md_filenames=all_md_filenames,
|
|
621
|
-
image_filenames=all_image_filenames,
|
|
622
|
-
css_filenames=all_css_filenames,
|
|
623
|
-
description_data=json_data
|
|
624
|
-
),
|
|
625
|
-
zipfile.ZIP_DEFLATED
|
|
626
|
-
)
|
|
627
|
-
|
|
628
|
-
# Write cover page
|
|
629
|
-
coverpage_data = get_coverpage_XML(title, authors)
|
|
630
|
-
epub.writestr("OPS/titlepage.xhtml", coverpage_data.encode('utf-8'), zipfile.ZIP_DEFLATED)
|
|
631
|
-
|
|
632
|
-
# Write processed chapters
|
|
633
|
-
print("Writing chapters...")
|
|
634
|
-
for i, chapter in enumerate(json_data["chapters"]):
|
|
635
|
-
print(f" Writing chapter {i+1}/{len(json_data['chapters'])}: {chapter['markdown']}")
|
|
636
|
-
epub.writestr(
|
|
637
|
-
f"OPS/s{i:05d}-{chapter['markdown'].split('.')[0]}.xhtml",
|
|
638
|
-
chapter_data[chapter["markdown"]].encode('utf-8'),
|
|
639
|
-
zipfile.ZIP_DEFLATED
|
|
640
|
-
)
|
|
641
|
-
|
|
642
|
-
# Write processed images
|
|
643
|
-
if processed_images:
|
|
644
|
-
print(f"Writing {len(processed_images)} processed images...")
|
|
645
|
-
for image_name, image_data in processed_images.items():
|
|
646
|
-
epub.writestr(f"OPS/images/{image_name}", image_data, zipfile.ZIP_DEFLATED)
|
|
647
|
-
|
|
648
|
-
# Write TOC files
|
|
649
|
-
print("Writing table of contents...")
|
|
650
|
-
epub.writestr("OPS/TOC.xhtml",
|
|
651
|
-
get_TOC_XML(json_data["default_css"], all_md_filenames),
|
|
652
|
-
zipfile.ZIP_DEFLATED
|
|
653
|
-
)
|
|
654
|
-
|
|
655
|
-
epub.writestr("OPS/toc.ncx",
|
|
656
|
-
get_TOCNCX_XML(
|
|
657
|
-
all_md_filenames,
|
|
658
|
-
uid=json_data["metadata"].get("dc:identifier", ""),
|
|
659
|
-
title=json_data["metadata"].get("dc:title", "")
|
|
660
|
-
),
|
|
661
|
-
zipfile.ZIP_DEFLATED
|
|
662
|
-
)
|
|
663
|
-
|
|
664
|
-
# Copy remaining images that weren't referenced in markdown
|
|
665
|
-
remaining_images = set(all_image_filenames) - set(processed_images.keys())
|
|
666
|
-
if remaining_images and os.path.exists(images_dir):
|
|
667
|
-
print(f"Writing {len(remaining_images)} additional images...")
|
|
668
|
-
for image in remaining_images:
|
|
669
|
-
with open(os.path.join(images_dir, image), "rb") as f:
|
|
670
|
-
epub.writestr(f"OPS/images/{image}", f.read(), zipfile.ZIP_DEFLATED)
|
|
671
|
-
|
|
672
|
-
# Copy CSS files; write a default style.css for any that are missing
|
|
673
|
-
default_css_content = b"""body { font-family: serif; line-height: 1.5; margin: 5%; }
|
|
674
|
-
h1, h2, h3, h4, h5, h6 { font-family: sans-serif; }
|
|
675
|
-
img { max-width: 100%; height: auto; }
|
|
676
|
-
pre, code { font-family: monospace; font-size: 0.9em; }
|
|
677
|
-
"""
|
|
678
|
-
print(f"Writing {len(all_css_filenames)} CSS files...")
|
|
679
|
-
for css in all_css_filenames:
|
|
680
|
-
css_path = os.path.join(css_dir, css)
|
|
681
|
-
if os.path.exists(css_path):
|
|
682
|
-
with open(css_path, "rb") as f:
|
|
683
|
-
epub.writestr(f"OPS/css/{css}", f.read(), zipfile.ZIP_DEFLATED)
|
|
684
|
-
else:
|
|
685
|
-
epub.writestr(f"OPS/css/{css}", default_css_content, zipfile.ZIP_DEFLATED)
|
|
686
|
-
|
|
687
|
-
print(f"\nEPUB creation complete: {output_path}")
|
|
688
|
-
|
|
689
|
-
except Exception:
|
|
690
|
-
import traceback
|
|
691
|
-
print(f"Error processing {work_dir}:")
|
|
692
|
-
print(traceback.format_exc())
|
|
693
|
-
raise
|
|
694
|
-
|
|
695
|
-
if __name__ == "__main__":
|
|
696
|
-
main(sys.argv[1:])
|