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.
@@ -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'![{alt_text}](images/{actual.name})'
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:])