feat: Add EPUB generation tool and workflow
- Introduced a new script to build an EPUB from markdown files, enhancing documentation accessibility. - Added a GitHub Actions workflow for automated EPUB builds on version tag pushes. - Created the initial EPUB file 'claude-howto-guide.epub' for distribution. This update streamlines the process of creating and releasing the Claude How-To guide in EPUB format.
This commit is contained in:
27
.github/workflows/release.yml
vendored
Normal file
27
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Build and Release EPUB
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Build EPUB
|
||||||
|
run: uv run scripts/build_epub.py
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: claude-howto-guide.epub
|
||||||
|
generate_release_notes: true
|
||||||
BIN
claude-howto-guide.epub
Normal file
BIN
claude-howto-guide.epub
Normal file
Binary file not shown.
@@ -4,8 +4,32 @@
|
|||||||
# ///
|
# ///
|
||||||
"""
|
"""
|
||||||
Build an EPUB from the Claude How-To markdown files.
|
Build an EPUB from the Claude How-To markdown files.
|
||||||
Chapters are organized by folder structure.
|
|
||||||
Renders mermaid diagrams using Kroki.io API.
|
Usage:
|
||||||
|
Run from the repository root directory:
|
||||||
|
./scripts/build_epub.py
|
||||||
|
|
||||||
|
Or run directly with Python/uv:
|
||||||
|
uv run scripts/build_epub.py
|
||||||
|
python scripts/build_epub.py
|
||||||
|
|
||||||
|
The script uses inline script dependencies (PEP 723), so uv will
|
||||||
|
automatically install required packages in an isolated environment.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
Creates 'claude-howto-guide.epub' in the repository root directory.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Organizes chapters by folder structure (01-slash-commands, etc.)
|
||||||
|
- Renders Mermaid diagrams as PNG images via Kroki.io API
|
||||||
|
- Generates a cover image from the project logo
|
||||||
|
- Converts internal markdown links to EPUB chapter references
|
||||||
|
- Handles SVG images by replacing with alt text (unsupported in EPUB)
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- uv (recommended) or Python 3.10+ with dependencies installed
|
||||||
|
- Internet connection for Mermaid diagram rendering
|
||||||
|
- Repository structure with markdown files and claude-howto-logo.png
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -14,16 +38,19 @@ import zlib
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import httpx
|
import httpx
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from PIL import Image
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from ebooklib import epub
|
from ebooklib import epub
|
||||||
import markdown
|
import markdown
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
# Cache for mermaid images to avoid re-fetching
|
# Cache for mermaid images to avoid re-fetching (stores (image_data, filename) tuples)
|
||||||
_mermaid_cache: dict[str, bytes] = {}
|
_mermaid_cache: dict[str, tuple[bytes, str]] = {}
|
||||||
_mermaid_counter = 0
|
_mermaid_counter = 0
|
||||||
|
|
||||||
|
# Track which mermaid images have been added to the book
|
||||||
|
_mermaid_added_to_book: set[str] = set()
|
||||||
|
|
||||||
# Mapping from source paths to EPUB chapter filenames
|
# Mapping from source paths to EPUB chapter filenames
|
||||||
_path_to_chapter: dict[str, str] = {}
|
_path_to_chapter: dict[str, str] = {}
|
||||||
|
|
||||||
@@ -32,11 +59,10 @@ def mermaid_to_image(mermaid_code: str) -> tuple[bytes, str] | None:
|
|||||||
"""Convert mermaid code to PNG image using Kroki.io API."""
|
"""Convert mermaid code to PNG image using Kroki.io API."""
|
||||||
global _mermaid_counter
|
global _mermaid_counter
|
||||||
|
|
||||||
# Check cache
|
# Check cache - return cached image data and filename to avoid duplicates
|
||||||
cache_key = mermaid_code.strip()
|
cache_key = mermaid_code.strip()
|
||||||
if cache_key in _mermaid_cache:
|
if cache_key in _mermaid_cache:
|
||||||
_mermaid_counter += 1
|
return _mermaid_cache[cache_key]
|
||||||
return _mermaid_cache[cache_key], f"mermaid_{_mermaid_counter}.png"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use Kroki.io API - accepts deflate-compressed, base64-encoded diagrams
|
# Use Kroki.io API - accepts deflate-compressed, base64-encoded diagrams
|
||||||
@@ -48,8 +74,9 @@ def mermaid_to_image(mermaid_code: str) -> tuple[bytes, str] | None:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
_mermaid_counter += 1
|
_mermaid_counter += 1
|
||||||
img_data = response.content
|
img_data = response.content
|
||||||
_mermaid_cache[cache_key] = img_data
|
img_name = f"mermaid_{_mermaid_counter}.png"
|
||||||
return img_data, f"mermaid_{_mermaid_counter}.png"
|
_mermaid_cache[cache_key] = (img_data, img_name)
|
||||||
|
return img_data, img_name
|
||||||
else:
|
else:
|
||||||
print(f" Warning: Kroki API returned {response.status_code}")
|
print(f" Warning: Kroki API returned {response.status_code}")
|
||||||
return None
|
return None
|
||||||
@@ -81,14 +108,16 @@ def process_mermaid_blocks(md_content: str, book: epub.EpubBook) -> str:
|
|||||||
result = mermaid_to_image(mermaid_code)
|
result = mermaid_to_image(mermaid_code)
|
||||||
if result:
|
if result:
|
||||||
img_data, img_name = result
|
img_data, img_name = result
|
||||||
# Add image to book
|
# Only add image to book if not already added
|
||||||
img_item = epub.EpubItem(
|
if img_name not in _mermaid_added_to_book:
|
||||||
uid=img_name.replace('.', '_'),
|
img_item = epub.EpubItem(
|
||||||
file_name=f"images/{img_name}",
|
uid=img_name.replace('.', '_'),
|
||||||
media_type="image/png",
|
file_name=f"images/{img_name}",
|
||||||
content=img_data
|
media_type="image/png",
|
||||||
)
|
content=img_data
|
||||||
book.add_item(img_item)
|
)
|
||||||
|
book.add_item(img_item)
|
||||||
|
_mermaid_added_to_book.add(img_name)
|
||||||
# Return markdown image reference
|
# Return markdown image reference
|
||||||
return f'\n\n'
|
return f'\n\n'
|
||||||
else:
|
else:
|
||||||
@@ -98,26 +127,114 @@ def process_mermaid_blocks(md_content: str, book: epub.EpubBook) -> str:
|
|||||||
return re.sub(pattern, replace_mermaid, md_content, flags=re.DOTALL)
|
return re.sub(pattern, replace_mermaid, md_content, flags=re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
def create_cover_image(logo_path: Path) -> bytes:
|
def create_cover_image(logo_path: Path, title: str = "Claude Code\nHow-To Guide") -> bytes:
|
||||||
"""Create a cover image by cropping a square from the left of the logo."""
|
"""Create a cover image by composing the logo with title text on top.
|
||||||
with Image.open(logo_path) as img:
|
|
||||||
# Convert to RGB if necessary (for PNG with transparency)
|
|
||||||
if img.mode in ('RGBA', 'P'):
|
|
||||||
background = Image.new('RGB', img.size, (30, 41, 59))
|
|
||||||
if img.mode == 'P':
|
|
||||||
img = img.convert('RGBA')
|
|
||||||
background.paste(img, mask=img.split()[3] if img.mode == 'RGBA' else None)
|
|
||||||
img = background
|
|
||||||
|
|
||||||
# Crop a square from the left side
|
Args:
|
||||||
_, height = img.size
|
logo_path: Path to the PNG logo file
|
||||||
square_size = height # Use height as the square size
|
title: Title text to overlay on the cover
|
||||||
img = img.crop((0, 0, square_size, square_size))
|
|
||||||
|
|
||||||
# Save to bytes
|
Returns:
|
||||||
buffer = BytesIO()
|
PNG image data as bytes
|
||||||
img.save(buffer, format='PNG', optimize=True)
|
"""
|
||||||
return buffer.getvalue()
|
# Target cover dimensions (standard ebook cover ratio ~1.6:1 height:width)
|
||||||
|
cover_width = 600
|
||||||
|
cover_height = 900
|
||||||
|
|
||||||
|
# Background color matching the logo gradient
|
||||||
|
bg_color = (26, 26, 46) # #1a1a2e from the logo
|
||||||
|
|
||||||
|
# Create the cover canvas
|
||||||
|
cover = Image.new('RGB', (cover_width, cover_height), bg_color)
|
||||||
|
draw = ImageDraw.Draw(cover)
|
||||||
|
|
||||||
|
# Load and scale the logo
|
||||||
|
with Image.open(logo_path) as logo:
|
||||||
|
# Scale logo to fit cover width with some padding
|
||||||
|
target_width = cover_width - 60 # 30px padding on each side
|
||||||
|
scale_factor = target_width / logo.width
|
||||||
|
new_height = int(logo.height * scale_factor)
|
||||||
|
logo_scaled = logo.resize((target_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Handle transparency
|
||||||
|
if logo_scaled.mode == 'RGBA':
|
||||||
|
# Composite onto a background matching cover color
|
||||||
|
logo_bg = Image.new('RGB', logo_scaled.size, bg_color)
|
||||||
|
logo_bg.paste(logo_scaled, mask=logo_scaled.split()[3])
|
||||||
|
logo_scaled = logo_bg
|
||||||
|
elif logo_scaled.mode != 'RGB':
|
||||||
|
logo_scaled = logo_scaled.convert('RGB')
|
||||||
|
|
||||||
|
# Position the logo in the lower portion of the cover
|
||||||
|
logo_x = (cover_width - logo_scaled.width) // 2
|
||||||
|
logo_y = cover_height - logo_scaled.height - 80 # 80px from bottom
|
||||||
|
cover.paste(logo_scaled, (logo_x, logo_y))
|
||||||
|
|
||||||
|
# Add title text at the top
|
||||||
|
# Try to use a nice font, fall back to default
|
||||||
|
font_size = 72
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
try:
|
||||||
|
# Try common system fonts on macOS
|
||||||
|
for font_name in [
|
||||||
|
'/System/Library/Fonts/Supplemental/Arial Bold.ttf',
|
||||||
|
'/System/Library/Fonts/Helvetica.ttc',
|
||||||
|
'Arial Bold',
|
||||||
|
'Helvetica Bold',
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(font_name, font_size)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Draw title text (centered, near top)
|
||||||
|
title_color = (78, 205, 196) # #4ecdc4 - teal from the logo gradient
|
||||||
|
|
||||||
|
# Split title into lines and draw each centered
|
||||||
|
lines = title.split('\n')
|
||||||
|
y_offset = 120
|
||||||
|
line_spacing = 90
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
bbox = draw.textbbox((0, 0), line, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
x = (cover_width - text_width) // 2
|
||||||
|
draw.text((x, y_offset), line, font=font, fill=title_color)
|
||||||
|
y_offset += line_spacing
|
||||||
|
|
||||||
|
# Add a subtle subtitle
|
||||||
|
subtitle = "Complete Guide to Claude Code Features"
|
||||||
|
subtitle_font_size = 24
|
||||||
|
subtitle_font = ImageFont.load_default()
|
||||||
|
try:
|
||||||
|
for font_name in [
|
||||||
|
'/System/Library/Fonts/Supplemental/Arial.ttf',
|
||||||
|
'/System/Library/Fonts/Helvetica.ttc',
|
||||||
|
'Arial',
|
||||||
|
'Helvetica',
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
subtitle_font = ImageFont.truetype(font_name, subtitle_font_size)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
subtitle_color = (168, 178, 209) # #a8b2d1 - light gray from logo
|
||||||
|
bbox = draw.textbbox((0, 0), subtitle, font=subtitle_font)
|
||||||
|
subtitle_width = bbox[2] - bbox[0]
|
||||||
|
subtitle_x = (cover_width - subtitle_width) // 2
|
||||||
|
subtitle_y = y_offset + 20
|
||||||
|
draw.text((subtitle_x, subtitle_y), subtitle, font=subtitle_font, fill=subtitle_color)
|
||||||
|
|
||||||
|
# Save to bytes
|
||||||
|
buffer = BytesIO()
|
||||||
|
cover.save(buffer, format='PNG', optimize=True)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def get_chapter_order():
|
def get_chapter_order():
|
||||||
@@ -263,7 +380,7 @@ def create_epub(root_path: Path, output_path: Path):
|
|||||||
book.set_language('en')
|
book.set_language('en')
|
||||||
book.add_author('Claude Code Community')
|
book.add_author('Claude Code Community')
|
||||||
|
|
||||||
# Add cover image
|
# Add cover image from PNG logo
|
||||||
logo_path = root_path / "claude-howto-logo.png"
|
logo_path = root_path / "claude-howto-logo.png"
|
||||||
if logo_path.exists():
|
if logo_path.exists():
|
||||||
print("Adding cover image...")
|
print("Adding cover image...")
|
||||||
Reference in New Issue
Block a user