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:
Willy Hardy
2025-12-09 12:07:40 -05:00
parent a737d687eb
commit 57ff23f9e0
3 changed files with 181 additions and 37 deletions

27
.github/workflows/release.yml vendored Normal file
View 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

Binary file not shown.

View File

@@ -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![Diagram](images/{img_name})\n' return f'\n![Diagram](images/{img_name})\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...")