close

DEV Community

German Yamil
German Yamil

Posted on

Generating Ebook Covers Programmatically with Python and the Imagen API

Every ebook needs two covers: one for KDP (1600x2560px at 300 DPI) and one for Gumroad (1280x720px). Designing both by hand for every book iteration wastes time. This article walks through a Python pipeline that calls an image generation API, post-processes the result with Pillow, validates dimensions and file size, and saves both variants automatically.

Why Dimensions Matter

KDP rejects covers that don't meet their specs. The required ratio is 1:1.6 (width:height). At 1600x2560px and 300 DPI, the physical print size is roughly 5.3" x 8.5" — standard trade paperback. Gumroad thumbnails display at 1280x720px (16:9), which is entirely different. If you upload your KDP cover to Gumroad, it will be cropped or letterboxed.

File size limits also apply: KDP caps cover images at 50MB (you'll rarely hit this with JPEG), and Gumroad has a 50MB limit for product thumbnails. Always validate before uploading.

Calling an Image Generation API

The pattern below works with any REST-based image generation API (Imagen, Stability AI, DALL-E, etc.). Swap the endpoint and auth header for your provider.

import os
import requests
import base64
from pathlib import Path

API_URL = "https://your-image-api-endpoint/generate"
API_KEY = os.environ["IMAGE_API_KEY"]

def generate_cover_image(prompt: str) -> bytes:
    """Call image generation API and return raw image bytes."""
    payload = {
        "prompt": prompt,
        "width": 1600,
        "height": 2560,
        "num_outputs": 1,
        "output_format": "png",
    }
    headers = {"Authorization": f"Bearer {API_KEY}"}
    response = requests.post(API_URL, json=payload, headers=headers, timeout=120)
    response.raise_for_status()

    data = response.json()
    # Most APIs return base64-encoded image data
    image_b64 = data["images"][0]
    return base64.b64decode(image_b64)
Enter fullscreen mode Exit fullscreen mode

Save the returned bytes to disk before any post-processing. Never process in-memory only — if the next step crashes, you lose the API call.

def save_raw(image_bytes: bytes, path: Path) -> None:
    path.write_bytes(image_bytes)
    print(f"Saved raw image: {path} ({len(image_bytes) / 1024:.1f} KB)")
Enter fullscreen mode Exit fullscreen mode

Pillow Post-Processing

Raw API output is rarely production-ready. You need to resize, set DPI metadata, add a text overlay, and export at the correct quality.

from PIL import Image, ImageDraw, ImageFont
import io

def add_text_overlay(img: Image.Image, title: str, subtitle: str) -> Image.Image:
    """Add title and subtitle text to the bottom third of the cover."""
    draw = ImageDraw.Draw(img)
    width, height = img.size

    # Semi-transparent dark band at the bottom
    overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
    overlay_draw = ImageDraw.Draw(overlay)
    overlay_draw.rectangle(
        [(0, int(height * 0.65)), (width, height)],
        fill=(0, 0, 0, 180),
    )
    img = Image.alpha_composite(img.convert("RGBA"), overlay)

    draw = ImageDraw.Draw(img)

    # Use a default PIL font — replace with a TTF for production
    try:
        title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 80)
        sub_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 48)
    except OSError:
        title_font = ImageFont.load_default()
        sub_font = ImageFont.load_default()

    # Center title
    title_bbox = draw.textbbox((0, 0), title, font=title_font)
    title_x = (width - (title_bbox[2] - title_bbox[0])) // 2
    draw.text((title_x, int(height * 0.70)), title, fill="white", font=title_font)

    # Center subtitle
    sub_bbox = draw.textbbox((0, 0), subtitle, font=sub_font)
    sub_x = (width - (sub_bbox[2] - sub_bbox[0])) // 2
    draw.text((sub_x, int(height * 0.82)), subtitle, fill="#cccccc", font=sub_font)

    return img.convert("RGB")
Enter fullscreen mode Exit fullscreen mode

Saving Both Variants

From one generated image, produce both the KDP and Gumroad versions:

def save_kdp_cover(img: Image.Image, output_path: Path) -> Path:
    """Resize to 1600x2560 at 300 DPI and save as JPEG."""
    kdp = img.resize((1600, 2560), Image.LANCZOS)
    kdp.save(output_path, format="JPEG", quality=95, dpi=(300, 300))
    print(f"KDP cover saved: {output_path}")
    return output_path

def save_gumroad_cover(img: Image.Image, output_path: Path) -> Path:
    """
    Crop center of the KDP cover to 16:9, then resize to 1280x720.
    The KDP cover is portrait; we take the upper portion for Gumroad.
    """
    width, height = img.size
    target_h = int(width * 720 / 1280)  # height that gives 16:9 at this width
    top = int(height * 0.10)            # start 10% down to avoid empty sky
    crop_box = (0, top, width, top + target_h)
    cropped = img.crop(crop_box)
    gumroad = cropped.resize((1280, 720), Image.LANCZOS)
    gumroad.save(output_path, format="JPEG", quality=90)
    print(f"Gumroad cover saved: {output_path}")
    return output_path
Enter fullscreen mode Exit fullscreen mode

File Validation Before Use

Before shipping covers to KDP or uploading to Gumroad, validate dimensions and file size programmatically:

def validate_cover(path: Path, expected_w: int, expected_h: int, max_mb: float = 50.0) -> bool:
    """Return True if the cover meets dimension and size requirements."""
    errors = []

    size_mb = path.stat().st_size / (1024 * 1024)
    if size_mb > max_mb:
        errors.append(f"File too large: {size_mb:.1f} MB (max {max_mb} MB)")

    with Image.open(path) as img:
        w, h = img.size
        if w != expected_w or h != expected_h:
            errors.append(f"Wrong dimensions: {w}x{h} (expected {expected_w}x{expected_h})")

    if errors:
        for e in errors:
            print(f"VALIDATION ERROR: {e}")
        return False

    print(f"Validation passed: {path.name} ({size_mb:.2f} MB, {expected_w}x{expected_h})")
    return True

# Usage
kdp_path   = Path("outputs/cover_kdp.jpg")
gumrd_path = Path("outputs/cover_gumroad.jpg")

assert validate_cover(kdp_path,   1600, 2560, max_mb=50)
assert validate_cover(gumrd_path, 1280,  720, max_mb=50)
Enter fullscreen mode Exit fullscreen mode

Putting It Together

def run_cover_pipeline(title: str, subtitle: str, prompt: str) -> None:
    out = Path("outputs")
    out.mkdir(exist_ok=True)

    raw_bytes = generate_cover_image(prompt)
    raw_path  = out / "cover_raw.png"
    save_raw(raw_bytes, raw_path)

    with Image.open(raw_path) as base_img:
        final = add_text_overlay(base_img.copy(), title, subtitle)

    kdp_path   = out / "cover_kdp.jpg"
    gumrd_path = out / "cover_gumroad.jpg"

    save_kdp_cover(final, kdp_path)
    save_gumroad_cover(final, gumrd_path)

    validate_cover(kdp_path,   1600, 2560)
    validate_cover(gumrd_path, 1280,  720)

if __name__ == "__main__":
    run_cover_pipeline(
        title="Python Automation",
        subtitle="Build pipelines that ship",
        prompt="Minimalist tech book cover, dark background, glowing circuit patterns, professional",
    )
Enter fullscreen mode Exit fullscreen mode

The entire pipeline runs in under 90 seconds including the API call. Swap the font paths for your system and drop in your preferred image API credentials. The validation step will catch any spec violations before you waste time on a KDP rejection.

Full pipeline + source code: germy5.gumroad.com/l/xhxkzz — $19.99, 30-day refund.


📋 Free resource: AI Publishing Checklist — 7 steps to ship a technical ebook with Python (free PDF)

Full pipeline + 10 scripts: germy5.gumroad.com/l/xhxkzz — $12.99 launch price

Top comments (0)