Compare commits
2 Commits
d1d9a07fd7
...
14ee7f61fb
| Author | SHA1 | Date |
|---|---|---|
|
|
14ee7f61fb | |
|
|
97d14c47a7 |
|
|
@ -0,0 +1,28 @@
|
|||
name: Mirror to Gitea
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Mirror to Gitea
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_USERNAME: ${{ secrets.GITEA_USERNAME }}
|
||||
run: |
|
||||
REPO_NAME=$(basename $GITHUB_REPOSITORY)
|
||||
git remote add gitea https://$GITEA_USERNAME:$GITEA_TOKEN@gitea.jeffemmett.com/jeffemmett/$REPO_NAME.git || true
|
||||
git push gitea --all --force
|
||||
git push gitea --tags --force
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add or update Table of Contents for an EPUB built by pdf_to_epub.
|
||||
|
||||
Since the PDF has no embedded TOC, chapter markers are defined manually
|
||||
in a JSON config file. This script patches an existing EPUB's navigation.
|
||||
|
||||
Usage:
|
||||
python3 converter/add_toc.py output/ExploringMycoFiBook.epub --toc toc_mycofi.json
|
||||
|
||||
TOC JSON format:
|
||||
[
|
||||
{"title": "Cover", "page": 1},
|
||||
{"title": "Introduction", "page": 5},
|
||||
{"title": "Chapter 1: Mycelial Networks", "page": 12},
|
||||
...
|
||||
]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from ebooklib import epub
|
||||
|
||||
|
||||
def patch_toc(epub_path: str, toc_entries: list[dict]) -> str:
|
||||
"""Patch the TOC of an existing EPUB with manual chapter markers.
|
||||
|
||||
Args:
|
||||
epub_path: Path to the EPUB file
|
||||
toc_entries: List of {"title": str, "page": int} dicts (1-indexed pages)
|
||||
|
||||
Returns:
|
||||
Path to the patched EPUB
|
||||
"""
|
||||
book = epub.read_epub(epub_path)
|
||||
|
||||
# Find all page items sorted by filename
|
||||
pages = sorted(
|
||||
[item for item in book.get_items() if item.file_name.startswith("pages/")],
|
||||
key=lambda x: x.file_name,
|
||||
)
|
||||
|
||||
if not pages:
|
||||
print("Error: No page items found in EPUB")
|
||||
sys.exit(1)
|
||||
|
||||
# Build new TOC from entries
|
||||
new_toc = []
|
||||
for entry in toc_entries:
|
||||
page_idx = entry["page"] - 1 # Convert 1-indexed to 0-indexed
|
||||
if 0 <= page_idx < len(pages):
|
||||
page_item = pages[page_idx]
|
||||
# Create a link using the page's file_name
|
||||
new_toc.append(epub.Link(page_item.file_name, entry["title"], f"toc_{page_idx}"))
|
||||
else:
|
||||
print(f"Warning: Page {entry['page']} out of range (1-{len(pages)}), skipping: {entry['title']}")
|
||||
|
||||
book.toc = new_toc
|
||||
|
||||
# Re-add navigation items
|
||||
for item in list(book.get_items()):
|
||||
if isinstance(item, (epub.EpubNcx, epub.EpubNav)):
|
||||
book.items.remove(item)
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
|
||||
# Write back
|
||||
epub.write_epub(epub_path, book, {})
|
||||
print(f"Updated TOC with {len(new_toc)} entries in {epub_path}")
|
||||
return epub_path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Add/update EPUB table of contents")
|
||||
parser.add_argument("epub", help="Path to EPUB file")
|
||||
parser.add_argument("--toc", required=True, help="Path to TOC JSON file")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.toc) as f:
|
||||
toc_entries = json.load(f)
|
||||
|
||||
patch_toc(args.epub, toc_entries)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Batch converter — finds all PDFs in a directory and converts them to EPUB.
|
||||
|
||||
Usage:
|
||||
python3 converter/batch_convert.py /path/to/pdfs/ --output-dir output/
|
||||
python3 converter/batch_convert.py . --dpi 150 # lower DPI for smaller files
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pdf_to_epub import convert_pdf_to_epub
|
||||
|
||||
|
||||
# Known book metadata — add entries as we convert more flipbooks
|
||||
BOOK_METADATA = {
|
||||
"ExploringMycoFiBook.pdf": {
|
||||
"title": "Exploring MycoFi: Mycelial Design Patterns for Web3 and Beyond",
|
||||
"author": "Jeff Emmett & Contributors",
|
||||
"description": (
|
||||
"A Mycopunk publication from the Greenpill Network exploring "
|
||||
"how mycelial networks can inform the design of decentralized "
|
||||
"economic systems, DAOs, and Web3 infrastructure."
|
||||
),
|
||||
},
|
||||
"psilocybernetics.pdf": {
|
||||
"title": "Psilocybernetics",
|
||||
"author": "Jeff Emmett",
|
||||
"description": "An exploration of psychedelic-informed cybernetics.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def find_pdfs(directory: str) -> list[Path]:
|
||||
"""Find all PDF files in a directory (non-recursive)."""
|
||||
return sorted(Path(directory).glob("*.pdf"))
|
||||
|
||||
|
||||
def batch_convert(
|
||||
input_dir: str,
|
||||
output_dir: str = "output",
|
||||
dpi: int = 200,
|
||||
):
|
||||
"""Convert all PDFs found in input_dir to EPUBs in output_dir."""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
pdfs = find_pdfs(input_dir)
|
||||
|
||||
if not pdfs:
|
||||
print(f"No PDFs found in {input_dir}")
|
||||
return
|
||||
|
||||
print(f"Found {len(pdfs)} PDF(s) to convert:\n")
|
||||
for pdf in pdfs:
|
||||
print(f" - {pdf.name}")
|
||||
print()
|
||||
|
||||
results = []
|
||||
for pdf in pdfs:
|
||||
meta = BOOK_METADATA.get(pdf.name, {})
|
||||
output_path = os.path.join(output_dir, pdf.stem + ".epub")
|
||||
|
||||
print(f"{'=' * 60}")
|
||||
print(f"Converting: {pdf.name}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
try:
|
||||
result = convert_pdf_to_epub(
|
||||
pdf_path=str(pdf),
|
||||
output_path=output_path,
|
||||
title=meta.get("title"),
|
||||
author=meta.get("author"),
|
||||
dpi=dpi,
|
||||
description=meta.get("description", ""),
|
||||
)
|
||||
results.append((pdf.name, result, "OK"))
|
||||
except Exception as e:
|
||||
print(f"ERROR converting {pdf.name}: {e}")
|
||||
results.append((pdf.name, None, str(e)))
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print(f"\n{'=' * 60}")
|
||||
print("BATCH CONVERSION SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
for name, path, status in results:
|
||||
if status == "OK":
|
||||
size = os.path.getsize(path) / (1024 * 1024)
|
||||
print(f" OK {name} → {path} ({size:.1f} MB)")
|
||||
else:
|
||||
print(f" ERR {name}: {status}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Batch convert PDFs to fixed-layout EPUB")
|
||||
parser.add_argument("input_dir", help="Directory containing PDF files")
|
||||
parser.add_argument("--output-dir", "-o", default="output", help="Output directory (default: output/)")
|
||||
parser.add_argument("--dpi", type=int, default=200, help="Render DPI (default: 200)")
|
||||
|
||||
args = parser.parse_args()
|
||||
batch_convert(args.input_dir, args.output_dir, args.dpi)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
PDF to Fixed-Layout EPUB Converter
|
||||
|
||||
Converts visually-rich PDFs (like designed books from InDesign) into
|
||||
fixed-layout EPUB3 files suitable for Kindle and ebook readers.
|
||||
|
||||
Each PDF page becomes a full-page image in the EPUB, preserving the
|
||||
original design, typography, and layout.
|
||||
|
||||
Usage:
|
||||
python3 converter/pdf_to_epub.py input.pdf [--output output.epub] [--dpi 200]
|
||||
python3 converter/pdf_to_epub.py input.pdf --title "My Book" --author "Author Name"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import fitz # PyMuPDF
|
||||
from ebooklib import epub
|
||||
|
||||
|
||||
def extract_pages_as_images(pdf_path: str, dpi: int = 200) -> list[tuple[bytes, int, int]]:
|
||||
"""Extract each PDF page as a JPEG image.
|
||||
|
||||
Returns list of (image_bytes, width_px, height_px) tuples.
|
||||
"""
|
||||
doc = fitz.open(pdf_path)
|
||||
pages = []
|
||||
zoom = dpi / 72 # PDF is 72 DPI by default
|
||||
matrix = fitz.Matrix(zoom, zoom)
|
||||
|
||||
for i, page in enumerate(doc):
|
||||
pix = page.get_pixmap(matrix=matrix)
|
||||
img_bytes = pix.tobytes("jpeg", jpg_quality=92)
|
||||
pages.append((img_bytes, pix.width, pix.height))
|
||||
print(f" Extracted page {i + 1}/{doc.page_count} ({pix.width}x{pix.height})")
|
||||
|
||||
doc.close()
|
||||
return pages
|
||||
|
||||
|
||||
def extract_metadata(pdf_path: str) -> dict:
|
||||
"""Pull whatever metadata we can from the PDF."""
|
||||
doc = fitz.open(pdf_path)
|
||||
meta = doc.metadata
|
||||
doc.close()
|
||||
return {
|
||||
"title": meta.get("title", ""),
|
||||
"author": meta.get("author", ""),
|
||||
"subject": meta.get("subject", ""),
|
||||
}
|
||||
|
||||
|
||||
def build_fixed_layout_epub(
|
||||
pages: list[tuple[bytes, int, int]],
|
||||
title: str,
|
||||
author: str,
|
||||
output_path: str,
|
||||
language: str = "en",
|
||||
cover_page: int = 0,
|
||||
description: str = "",
|
||||
) -> str:
|
||||
"""Build a fixed-layout EPUB3 from page images.
|
||||
|
||||
Args:
|
||||
pages: List of (jpeg_bytes, width, height) per page
|
||||
title: Book title
|
||||
author: Book author
|
||||
output_path: Where to save the .epub
|
||||
language: Language code
|
||||
cover_page: Which page index to use as cover (default 0)
|
||||
description: Book description for metadata
|
||||
|
||||
Returns:
|
||||
Path to the created EPUB file
|
||||
"""
|
||||
book = epub.EpubBook()
|
||||
book_id = str(uuid.uuid4())
|
||||
|
||||
# -- Metadata --
|
||||
book.set_identifier(book_id)
|
||||
book.set_title(title)
|
||||
book.set_language(language)
|
||||
book.add_author(author)
|
||||
if description:
|
||||
book.add_metadata("DC", "description", description)
|
||||
|
||||
# Fixed-layout metadata (EPUB3 rendition properties)
|
||||
book.add_metadata(
|
||||
None,
|
||||
"meta",
|
||||
"pre-paginated",
|
||||
{"property": "rendition:layout"},
|
||||
)
|
||||
book.add_metadata(
|
||||
None,
|
||||
"meta",
|
||||
"auto",
|
||||
{"property": "rendition:orientation"},
|
||||
)
|
||||
book.add_metadata(
|
||||
None,
|
||||
"meta",
|
||||
"none",
|
||||
{"property": "rendition:spread"},
|
||||
)
|
||||
|
||||
# Use first page dimensions as viewport default
|
||||
_, vp_w, vp_h = pages[0] if pages else (None, 1024, 1366)
|
||||
|
||||
# -- Add cover image (metadata only, actual image added in page loop) --
|
||||
cover_bytes, _, _ = pages[cover_page]
|
||||
book.set_cover("images/cover.jpg", cover_bytes, create_page=False)
|
||||
|
||||
# -- CSS for fixed-layout pages --
|
||||
page_css = epub.EpubItem(
|
||||
uid="page_css",
|
||||
file_name="style/page.css",
|
||||
media_type="text/css",
|
||||
content=b"""
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.page-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
""",
|
||||
)
|
||||
book.add_item(page_css)
|
||||
|
||||
# -- Build page chapters --
|
||||
chapters = []
|
||||
for i, (img_bytes, w, h) in enumerate(pages):
|
||||
# Add image
|
||||
img_item = epub.EpubImage()
|
||||
img_item.file_name = f"images/page_{i:04d}.jpg"
|
||||
img_item.media_type = "image/jpeg"
|
||||
img_item.content = img_bytes
|
||||
book.add_item(img_item)
|
||||
|
||||
# Create HTML page with viewport matching image dimensions
|
||||
chapter = epub.EpubHtml(
|
||||
title=f"Page {i + 1}",
|
||||
file_name=f"pages/page_{i:04d}.xhtml",
|
||||
lang=language,
|
||||
)
|
||||
chapter.content = f"""<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width={w}, height={h}"/>
|
||||
<title>Page {i + 1}</title>
|
||||
<link rel="stylesheet" type="text/css" href="../style/page.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div><img class="page-image" src="../images/page_{i:04d}.jpg" alt="Page {i + 1}"/></div>
|
||||
</body>
|
||||
</html>""".encode("utf-8")
|
||||
chapter.add_item(page_css)
|
||||
book.add_item(chapter)
|
||||
chapters.append(chapter)
|
||||
|
||||
# -- Spine & TOC --
|
||||
book.spine = chapters
|
||||
# Simple TOC — just first, middle, last for now
|
||||
# Can be enhanced with actual chapter markers
|
||||
book.toc = [chapters[0]]
|
||||
if len(chapters) > 2:
|
||||
book.toc.append(chapters[len(chapters) // 2])
|
||||
if len(chapters) > 1:
|
||||
book.toc.append(chapters[-1])
|
||||
|
||||
# Required EPUB3 navigation
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
|
||||
# -- Write --
|
||||
epub.write_epub(output_path, book, {})
|
||||
return output_path
|
||||
|
||||
|
||||
def convert_pdf_to_epub(
|
||||
pdf_path: str,
|
||||
output_path: str | None = None,
|
||||
title: str | None = None,
|
||||
author: str | None = None,
|
||||
dpi: int = 200,
|
||||
description: str = "",
|
||||
) -> str:
|
||||
"""Main conversion function. Takes a PDF, produces a fixed-layout EPUB.
|
||||
|
||||
Args:
|
||||
pdf_path: Path to input PDF
|
||||
output_path: Path for output EPUB (default: same name as PDF with .epub)
|
||||
title: Override title (otherwise extracted from PDF metadata)
|
||||
author: Override author
|
||||
dpi: Resolution for page rendering (higher = sharper but larger file)
|
||||
description: Book description
|
||||
|
||||
Returns:
|
||||
Path to created EPUB
|
||||
"""
|
||||
pdf_path = str(Path(pdf_path).resolve())
|
||||
if not os.path.exists(pdf_path):
|
||||
print(f"Error: PDF not found: {pdf_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# Default output path
|
||||
if output_path is None:
|
||||
output_path = str(Path(pdf_path).with_suffix(".epub"))
|
||||
|
||||
# Extract metadata from PDF as fallback
|
||||
meta = extract_metadata(pdf_path)
|
||||
title = title or meta["title"] or Path(pdf_path).stem
|
||||
author = author or meta["author"] or "Unknown"
|
||||
|
||||
print(f"Converting: {pdf_path}")
|
||||
print(f" Title: {title}")
|
||||
print(f" Author: {author}")
|
||||
print(f" DPI: {dpi}")
|
||||
print()
|
||||
|
||||
# Extract pages
|
||||
print("Extracting pages...")
|
||||
pages = extract_pages_as_images(pdf_path, dpi=dpi)
|
||||
print(f"\n{len(pages)} pages extracted.")
|
||||
|
||||
# Build EPUB
|
||||
print(f"\nBuilding EPUB: {output_path}")
|
||||
result = build_fixed_layout_epub(
|
||||
pages=pages,
|
||||
title=title,
|
||||
author=author,
|
||||
output_path=output_path,
|
||||
description=description,
|
||||
)
|
||||
|
||||
file_size = os.path.getsize(result) / (1024 * 1024)
|
||||
print(f"\nDone! {result} ({file_size:.1f} MB)")
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert PDF to fixed-layout EPUB for Kindle/ebook readers"
|
||||
)
|
||||
parser.add_argument("pdf", help="Path to input PDF file")
|
||||
parser.add_argument("--output", "-o", help="Output EPUB path (default: same name as PDF)")
|
||||
parser.add_argument("--title", "-t", help="Book title (overrides PDF metadata)")
|
||||
parser.add_argument("--author", "-a", help="Book author (overrides PDF metadata)")
|
||||
parser.add_argument("--dpi", type=int, default=200, help="Render DPI (default: 200)")
|
||||
parser.add_argument("--description", "-d", default="", help="Book description")
|
||||
|
||||
args = parser.parse_args()
|
||||
convert_pdf_to_epub(
|
||||
pdf_path=args.pdf,
|
||||
output_path=args.output,
|
||||
title=args.title,
|
||||
author=args.author,
|
||||
dpi=args.dpi,
|
||||
description=args.description,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,3 @@
|
|||
PyMuPDF>=1.24.0
|
||||
ebooklib>=0.18
|
||||
Pillow>=10.0
|
||||
Binary file not shown.
|
|
@ -0,0 +1,27 @@
|
|||
[
|
||||
{"title": "Cover", "page": 1},
|
||||
{"title": "Synopsis", "page": 2},
|
||||
{"title": "Title Page", "page": 6},
|
||||
{"title": "Credits", "page": 7},
|
||||
{"title": "Contents", "page": 10},
|
||||
{"title": "Endorsements", "page": 11},
|
||||
{"title": "A Note from the Creators", "page": 13},
|
||||
{"title": "Foreword", "page": 17},
|
||||
{"title": "Introduction: Uncovering Nature's Economic Blueprints", "page": 21},
|
||||
{"title": "Design Pattern 1: Network Infrastructure", "page": 27},
|
||||
{"title": "Imagining Fungal Futures: Mesh Stability", "page": 31},
|
||||
{"title": "Design Pattern 2: Fractal Nature", "page": 35},
|
||||
{"title": "Imagining Fungal Futures: Endosymbiotic Finance", "page": 41},
|
||||
{"title": "Design Pattern 3: Emergent Coordination", "page": 45},
|
||||
{"title": "Imagining Fungal Futures: Adaptive Myco-Organizations", "page": 49},
|
||||
{"title": "Design Pattern 4: Dynamic Flow", "page": 53},
|
||||
{"title": "Imagining Fungal Futures: Vote Streaming", "page": 57},
|
||||
{"title": "Design Pattern 5: Mutual Reciprocity", "page": 61},
|
||||
{"title": "Imagining Fungal Futures: Generosity Networks", "page": 65},
|
||||
{"title": "Design Pattern 6: Polycentric Pluralism", "page": 69},
|
||||
{"title": "Imagining Fungal Futures: Collective Flourishing", "page": 73},
|
||||
{"title": "Join the Mycelial Revolution", "page": 75},
|
||||
{"title": "Let's Get Rooted, Mycopunk", "page": 79},
|
||||
{"title": "Gratitude & Acknowledgments", "page": 82},
|
||||
{"title": "Appendix: References", "page": 83}
|
||||
]
|
||||
Loading…
Reference in New Issue