Brettapps commited on
Commit
e05c607
Β·
verified Β·
1 Parent(s): ab1e0b4

Update FairDinkumPublishing ebook pipeline

Browse files
requirements.txt CHANGED
@@ -1,4 +1,3 @@
1
  gradio>=5.0
2
- ebooklib>=0.20
3
  fpdf2>=2.8
4
  Pillow>=10.0
 
1
  gradio>=5.0
 
2
  fpdf2>=2.8
3
  Pillow>=10.0
src/__pycache__/pipeline_runner.cpython-313.pyc ADDED
Binary file (74.8 kB). View file
 
src/pipeline_runner.py CHANGED
@@ -8,7 +8,7 @@ Agents:
8
  2. Outline Architect β€” part/chapter/section structure
9
  3. Chapter Writer β€” full long-form chapters
10
  4. Cover Generator β€” Pillow front/back covers
11
- 5. Publisher β€” EPUB (ebooklib) + PDF (fpdf2) + Obsidian vault
12
 
13
  Usage:
14
  python3 pipeline_runner.py "Port Noarlunga jetty fishing South Australia"
@@ -19,14 +19,15 @@ from pathlib import Path
19
  # ── pip install inline (Space env) ──────────────────────────────────────────
20
  def ensure_deps():
21
  try:
22
- import ebooklib, fpdf, PIL # noqa: F401
23
  except ImportError:
24
  subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet",
25
- "ebooklib", "fpdf2", "Pillow"])
26
  ensure_deps()
27
 
28
- import ebooklib
29
- from ebooklib import epub
 
30
  from fpdf import FPDF
31
  from PIL import Image, ImageDraw, ImageFont
32
 
@@ -527,116 +528,197 @@ def _md_to_xhtml(md_text: str) -> str:
527
 
528
 
529
  def build_epub(concept: dict, outline: dict, out_path: str) -> str:
530
- book = epub.EpubBook()
531
- book.set_identifier(concept["id"])
532
- book.set_title(concept["title"])
533
- book.set_language("en-AU")
534
- book.add_author(concept.get("author", "Brett Anthony Sjoberg"))
535
- book.add_metadata("DC", "description", concept["description"])
536
- book.add_metadata("DC", "subject", ", ".join([concept["primary_keyword"]] + concept["secondary_keywords"]))
537
- book.add_metadata("DC", "publisher", concept.get("publisher", "Brett Anthony Sjoberg"))
538
- book.add_metadata("DC", "date", concept.get("created_at", "")[:10])
539
- book.add_metadata("DC", "rights",
540
- f"Β© {datetime.date.today().year} {concept.get('author', 'Brett Anthony Sjoberg')} β€” ABN {concept.get('abn', '')}")
541
- book.add_metadata("DC", "coverage", "Australia")
542
- book.add_metadata("DC", "type", "Text")
543
- book.add_metadata(None, "meta", "", {"name": "geo", "content": "Australia"})
544
- book.add_metadata(None, "meta", "", {"name": "keywords", "content": ", ".join(concept["geo_keywords"])})
545
-
546
- nav_css = epub.EpubItem(uid="style_nav", file_name="style/nav.css",
547
- media_type="text/css", content=EPUB_CSS)
548
- book.add_item(nav_css)
549
-
550
- spine = ["nav"]
551
- toc_links = []
552
-
553
- def make_ch(ch_num: int, title: str, body_xhtml: str):
554
- fname = f"text/ch{ch_num:02d}_{slugify(title)}.xhtml"
555
- item = epub.EpubHtml(title=title, file_name=fname, lang="en-AU")
556
- item.content = f"""<?xml version="1.0" encoding="UTF-8"?>
 
 
 
557
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
558
  <title>{title}</title><link rel="stylesheet" href="../style/nav.css"/></head>
559
  <body>{body_xhtml}</body></html>"""
560
- item.add_item(nav_css)
561
- book.add_item(item)
562
- spine.append(fname)
563
- toc_links.append(item)
564
 
565
  # Title page
566
- tp = epub.EpubHtml(title="Title Page", file_name="text/title_page.xhtml", lang="en-AU")
567
- tp.content = f"""<?xml version="1.0" encoding="UTF-8"?>
568
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
569
  <title>Title</title><link rel="stylesheet" href="../style/nav.css"/></head>
570
  <body><div class="cover-page">
571
  <h1>{concept['title']}</h1><h2>{concept['subtitle']}</h2>
572
- <p><em>by {concept.get('author', 'Authored Guide Australia')}</em></p>
573
  <p class='noindent'>{concept['primary_keyword']} β€” {', '.join(concept['geo_keywords'])}</p>
574
  </div></body></html>"""
575
- tp.add_item(nav_css); book.add_item(tp); spine.append("text/title_page.xhtml")
576
- toc_links.insert(0, tp)
577
-
578
- # Cover image
579
- front_jpg = COVERS / "front_cover.jpg"
580
- if front_jpg.exists():
581
- book.add_item(epub.EpubItem(uid="cover_img", file_name="images/cover.jpg",
582
- media_type="image/jpeg", content=front_jpg.read_bytes()))
583
- cov = epub.EpubHtml(title="Cover", file_name="text/cover.xhtml", lang="en-AU")
584
- cov.content = f"""<?xml version="1.0" encoding="UTF-8"?>
585
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
586
  <title>Cover</title><link rel="stylesheet" href="../style/nav.css"/></head>
587
  <body><div class="cover-page"><img class="cover" src="../images/cover.jpg" alt="Cover"/></div></body></html>"""
588
- cov.add_item(nav_css); book.add_item(cov); spine.insert(1, "text/cover.xhtml")
 
 
589
 
590
- # TOC page (hyperlinked)
591
- toc_x = epub.EpubHtml(title="Contents", file_name="text/toc_page.xhtml", lang="en-AU")
592
- toc_html = ["<nav epub:type='toc'><h2>Table of Contents</h2><ul>"]
593
  for part in outline["parts"]:
594
- toc_html.append(f"<li><span class='toc-part'>{part['name']}</span><ul>")
595
  for ch in part["chapters"]:
596
- toc_html.append(f"<li><a class='toc-entry' href='text/ch{int(ch['id'][2:]):02d}_{slugify(ch['title'])}.xhtml'>{ch['title']}</a></li>")
597
- toc_html.append("</ul></li>")
598
- toc_html.append("</ul></nav>")
599
- toc_x.content = f"""<?xml version="1.0" encoding="UTF-8"?>
 
 
600
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
601
  <title>Contents</title><link rel="stylesheet" href="../style/nav.css"/></head>
602
- <body>{chr(10).join(toc_html)}</body></html>"""
603
- toc_x.add_item(nav_css); book.add_item(toc_x); spine.append("text/toc_page.xhtml")
 
 
604
 
605
  # Chapters
606
  for part in outline["parts"]:
607
  for ch in part["chapters"]:
608
- md_path = CHAPTERS / f"{ch['id']}_{slugify(ch['title'])}.md"
 
609
  if md_path.exists():
610
  body = _md_to_xhtml(md_path.read_text(encoding="utf-8"))
611
  else:
612
  body = f"<h1>{ch['title']}</h1><p>Content coming soon.</p>"
613
- make_ch(int(ch["id"][2:]), ch["title"], body)
614
 
615
  # Back cover
616
- back = epub.EpubHtml(title="Back Cover", file_name="text/back_cover.xhtml", lang="en-AU")
617
- back_jpg = COVERS / "back_cover.jpg"
618
- if back_jpg.exists():
619
- book.add_item(epub.EpubItem(uid="back_img", file_name="images/back_cover.jpg",
620
- media_type="image/jpeg", content=back_jpg.read_bytes()))
621
- back.content = f"""<?xml version="1.0" encoding="UTF-8"?>
622
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
623
  <title>Back Cover</title><link rel="stylesheet" href="../style/nav.css"/></head>
624
- <body><div class="back-cover"><img class="cover" src="../images/back_cover.jpg" alt="Back Cover"/></div></body></html>"""
625
- else:
626
- back.content = f"""<?xml version="1.0" encoding="UTF-8"?>
 
 
627
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
628
  <title>Back Cover</title><link rel="stylesheet" href="../style/nav.css"/></head>
629
- <body><div class="back-cover"><h2>About This Book</h2><p>{concept['description']}</p></div></body></html>"""
630
- back.add_item(nav_css); book.add_item(back); spine.append("text/back_cover.xhtml")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
 
632
- book.toc = toc_links
633
- book.add_item(epub.EpubNcx())
634
- book.spine = spine
 
 
 
 
 
 
 
 
 
 
 
 
635
 
636
- out_path_p = Path(out_path)
637
- out_path_p.parent.mkdir(parents=True, exist_ok=True)
638
- epub.write_epub(str(out_path_p), book, {"compress": True})
639
- return str(out_path_p)
 
 
 
640
 
641
 
642
  # ────────────────────────────────────────────────────────────────────────────
 
8
  2. Outline Architect β€” part/chapter/section structure
9
  3. Chapter Writer β€” full long-form chapters
10
  4. Cover Generator β€” Pillow front/back covers
11
+ 5. Publisher β€” EPUB (zipfile) + PDF (fpdf2) + Obsidian vault
12
 
13
  Usage:
14
  python3 pipeline_runner.py "Port Noarlunga jetty fishing South Australia"
 
19
  # ── pip install inline (Space env) ──────────────────────────────────────────
20
  def ensure_deps():
21
  try:
22
+ import fpdf, PIL # noqa: F401
23
  except ImportError:
24
  subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet",
25
+ "fpdf2", "Pillow"])
26
  ensure_deps()
27
 
28
+ import zipfile
29
+ from fpdf import FPDF
30
+ from PIL import Image, ImageDraw, ImageFont
31
  from fpdf import FPDF
32
  from PIL import Image, ImageDraw, ImageFont
33
 
 
528
 
529
 
530
  def build_epub(concept: dict, outline: dict, out_path: str) -> str:
531
+ """Build a valid EPUB 3 package using zipfile (no fpdf2 dependency)."""
532
+ out = Path(out_path)
533
+ out.parent.mkdir(parents=True, exist_ok=True)
534
+
535
+ def slugify_toc(text):
536
+ text = text.lower()
537
+ text = re.sub(r"[^a-z0-9]+", "_", text)
538
+ text = re.sub(r"_+", "_", text).strip("_")
539
+ return text[:60]
540
+
541
+ chapters = []
542
+ for part in outline["parts"]:
543
+ for ch in part["chapters"]:
544
+ chapters.append(ch)
545
+
546
+ # ── OPF manifest entries ────────────────────────────────────────────────
547
+ manifest_items = [
548
+ '<item id="style" href="style/nav.css" media-type="text/css"/>',
549
+ ]
550
+ if (COVERS / "front_cover.jpg").exists():
551
+ manifest_items.append('<item id="cover" href="images/cover.jpg" media-type="image/jpeg"/>')
552
+ if (COVERS / "back_cover.jpg").exists():
553
+ manifest_items.append('<item id="back" href="images/back_cover.jpg" media-type="image/jpeg"/>')
554
+
555
+ spine_ids = ["titlepage"]
556
+ toc_entries = []
557
+
558
+ def add_chapter_xhtml(ch_num, title, body_xhtml, spine_id):
559
+ fname = f"ch{ch_num:02d}_{slugify_toc(title)}.xhtml"
560
+ xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
561
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
562
  <title>{title}</title><link rel="stylesheet" href="../style/nav.css"/></head>
563
  <body>{body_xhtml}</body></html>"""
564
+ files[f"text/{fname}"] = xhtml.encode("utf-8")
565
+ manifest_items.append(f'<item id="{spine_id}" href="text/{fname}" media-type="application/xhtml+xml"/>')
566
+ spine_ids.append(spine_id)
567
+ toc_entries.append((spine_id, title, f"text/{fname}"))
568
 
569
  # Title page
570
+ tp_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
 
571
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
572
  <title>Title</title><link rel="stylesheet" href="../style/nav.css"/></head>
573
  <body><div class="cover-page">
574
  <h1>{concept['title']}</h1><h2>{concept['subtitle']}</h2>
575
+ <p><em>by {concept.get('author', 'Brett Anthony Sjoberg')}</em></p>
576
  <p class='noindent'>{concept['primary_keyword']} β€” {', '.join(concept['geo_keywords'])}</p>
577
  </div></body></html>"""
578
+ files["text/title_page.xhtml"] = tp_xhtml.encode("utf-8")
579
+ manifest_items.append('<item id="titlepage" href="text/title_page.xhtml" media-type="application/xhtml+xml"/>')
580
+
581
+ # Cover page
582
+ if (COVERS / "front_cover.jpg").exists():
583
+ files["images/cover.jpg"] = (COVERS / "front_cover.jpg").read_bytes()
584
+ cov_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
 
 
 
585
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
586
  <title>Cover</title><link rel="stylesheet" href="../style/nav.css"/></head>
587
  <body><div class="cover-page"><img class="cover" src="../images/cover.jpg" alt="Cover"/></div></body></html>"""
588
+ files["text/cover.xhtml"] = cov_xhtml.encode("utf-8")
589
+ manifest_items.append('<item id="coverpage" href="text/cover.xhtml" media-type="application/xhtml+xml"/>')
590
+ spine_ids.insert(1, "coverpage")
591
 
592
+ # TOC page
593
+ toc_xhtml_lines = ["<nav epub:type='toc'><h2>Table of Contents</h2><ul>"]
 
594
  for part in outline["parts"]:
595
+ toc_xhtml_lines.append(f"<li><span class='toc-part'>{part['name']}</span><ul>")
596
  for ch in part["chapters"]:
597
+ ch_num = int(ch["id"][2:])
598
+ href = f"text/ch{ch_num:02d}_{slugify_toc(ch['title'])}.xhtml"
599
+ toc_xhtml_lines.append(f"<li><a class='toc-entry' href='../{href}'>{ch['title']}</a></li>")
600
+ toc_xhtml_lines.append("</ul></li>")
601
+ toc_xhtml_lines.append("</ul></nav>")
602
+ toc_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
603
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
604
  <title>Contents</title><link rel="stylesheet" href="../style/nav.css"/></head>
605
+ <body>{chr(10).join(toc_xhtml_lines)}</body></html>"""
606
+ files["text/toc_page.xhtml"] = toc_xhtml.encode("utf-8")
607
+ manifest_items.append('<item id="tocpage" href="text/toc_page.xhtml" media-type="application/xhtml+xml"/>')
608
+ spine_ids.append("tocpage")
609
 
610
  # Chapters
611
  for part in outline["parts"]:
612
  for ch in part["chapters"]:
613
+ ch_num = int(ch["id"][2:])
614
+ md_path = CHAPTERS / f"{ch['id']}_{slugify_toc(ch['title'])}.md"
615
  if md_path.exists():
616
  body = _md_to_xhtml(md_path.read_text(encoding="utf-8"))
617
  else:
618
  body = f"<h1>{ch['title']}</h1><p>Content coming soon.</p>"
619
+ add_chapter_xhtml(ch_num, ch["title"], body, f"ch{ch_num:02d}")
620
 
621
  # Back cover
622
+ back_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
 
 
 
 
 
623
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
624
  <title>Back Cover</title><link rel="stylesheet" href="../style/nav.css"/></head>
625
+ <body><div class="back-cover">
626
+ <h2>About This Book</h2><p>{concept['description']}</p></div></body></html>"""
627
+ if (COVERS / "back_cover.jpg").exists():
628
+ files["images/back_cover.jpg"] = (COVERS / "back_cover.jpg").read_bytes()
629
+ back_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
630
  <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
631
  <title>Back Cover</title><link rel="stylesheet" href="../style/nav.css"/></head>
632
+ <body><div class="back-cover"><img class="cover" src="../images/back_cover.jpg" alt="Back Cover"/></div></body></html>"""
633
+ files["text/back_cover.xhtml"] = back_xhtml.encode("utf-8")
634
+ manifest_items.append('<item id="backcover" href="text/back_cover.xhtml" media-type="application/xhtml+xml"/>')
635
+ spine_ids.append("backcover")
636
+
637
+ # ── NCX ─────────────────────────────────────────────────────────────────
638
+ ncx_nav_points = []
639
+ for spine_id, title, href in toc_entries:
640
+ ncx_nav_points.append(f"""<navPoint id="{spine_id}" playOrder="{spine_ids.index(spine_id) + 1}">
641
+ <navLabel><text>{title}</text></navLabel>
642
+ <content src="{href}"/>
643
+ </navPoint>""")
644
+ toc_ncx = f"""<?xml version="1.0" encoding="UTF-8"?>
645
+ <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
646
+ <head><meta name="dtb:uid" content="{concept['id']}"/>
647
+ <meta name="dtb:depth" content="1"/><meta name="dtb:totalPageCount" content="0"/>
648
+ <meta name="dtb:maxPageNumber" content="0"/></head>
649
+ <docTitle><text>{concept['title']}</text></docTitle>
650
+ <navMap>{chr(10).join(ncx_nav_points)}</navMap>
651
+ </ncx>"""
652
+
653
+ # ── Container ────────────────────────────────────────────────────────────
654
+ container_xml = """<?xml version="1.0" encoding="UTF-8"?>
655
+ <container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
656
+ <rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
657
+ </container>"""
658
+
659
+ # ── OPF ──────────────────────────────────────────────────────────────────
660
+ opf = f"""<?xml version="1.0" encoding="UTF-8"?>
661
+ <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
662
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
663
+ <dc:identifier id="uid">{concept['id']}</dc:identifier>
664
+ <dc:title>{concept['title']}</dc:title>
665
+ <dc:language>en-AU</dc:language>
666
+ <dc:creator>{concept.get('author', 'Brett Anthony Sjoberg')}</dc:creator>
667
+ <dc:publisher>{concept.get('publisher', 'Brett Anthony Sjoberg')}</dc:publisher>
668
+ <dc:description>{concept['description']}</dc:description>
669
+ <dc:subject>{', '.join([concept['primary_keyword']] + concept['secondary_keywords'])}</dc:subject>
670
+ <dc:date>{concept.get('created_at', '')[:10]}</dc:date>
671
+ <dc:rights>Β© {datetime.date.today().year} {concept.get('author', 'Brett Anthony Sjoberg')} β€” ABN {concept.get('abn', '')}</dc:rights>
672
+ <meta name="geo" content="Australia"/>
673
+ <meta name="keywords" content="{', '.join(concept['geo_keywords'])}"/>
674
+ </metadata>
675
+ <manifest>
676
+ {chr(10).join(manifest_items)}
677
+ </manifest>
678
+ <spine toc="toc">
679
+ {chr(10).join(f'<itemref idref="{sid}"/>' for sid in spine_ids)}
680
+ </spine>
681
+ </package>"""
682
+
683
+ # ── Assemble ZIP ─────────────────────────────────────────────────────────
684
+ files = {
685
+ "mimetype": b"application/epub+zip",
686
+ "META-INF/container.xml": container_xml.encode("utf-8"),
687
+ "OEBPS/content.opf": opf.encode("utf-8"),
688
+ "OEBPS/toc.ncx": toc_ncx.encode("utf-8"),
689
+ "OEBPS/style/nav.css": EPUB_CSS.encode("utf-8"),
690
+ "OEBPS/text/title_page.xhtml": tp_xhtml.encode("utf-8"),
691
+ "OEBPS/text/toc_page.xhtml": toc_xhtml.encode("utf-8"),
692
+ "OEBPS/text/back_cover.xhtml": back_xhtml.encode("utf-8"),
693
+ }
694
+ if (COVERS / "front_cover.jpg").exists():
695
+ files["OEBPS/images/cover.jpg"] = (COVERS / "front_cover.jpg").read_bytes()
696
+ if (COVERS / "back_cover.jpg").exists():
697
+ files["OEBPS/images/back_cover.jpg"] = (COVERS / "back_cover.jpg").read_bytes()
698
 
699
+ # Add chapters
700
+ for part in outline["parts"]:
701
+ for ch in part["chapters"]:
702
+ ch_num = int(ch["id"][2:])
703
+ md_path = CHAPTERS / f"{ch['id']}_{slugify_toc(ch['title'])}.md"
704
+ if md_path.exists():
705
+ body = _md_to_xhtml(md_path.read_text(encoding="utf-8"))
706
+ else:
707
+ body = f"<h1>{ch['title']}</h1><p>Content coming soon.</p>"
708
+ fname = f"OEBPS/text/ch{ch_num:02d}_{slugify_toc(ch['title'])}.xhtml"
709
+ xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
710
+ <!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head>
711
+ <title>{ch['title']}</title><link rel="stylesheet" href="../style/nav.css"/></head>
712
+ <body>{body}</body></html>"""
713
+ files[fname] = xhtml.encode("utf-8")
714
 
715
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
716
+ # mimetype must be first and uncompressed
717
+ zf.writestr("mimetype", files.pop("mimetype"), zipfile.ZIP_STORED)
718
+ for arcname, data in sorted(files.items()):
719
+ zf.writestr(arcname, data)
720
+
721
+ return str(out)
722
 
723
 
724
  # ────────────────────────────────────────────────────────────────────────────