#!/usr/bin/env python3 """ BookStack Cover Maker Takes an input SVG icon and produces a 440x250 PNG cover for BookStack. - Scales the SVG to a target height (default 210 px) while preserving aspect ratio. - Extracts the average color from the SVG (alpha-aware) and generates a simple, modern gradient from it. - Places the icon centered on top and exports an optimized PNG. Usage: python bookstack_cover_maker.py input.svg output.png \ --icon-height 210 \ --pad 16 Requirements: pip install pillow cairosvg numpy Notes: - If your environment lacks CairoSVG, install system deps for Cairo/Pango as needed. """ from __future__ import annotations import argparse import io import math from typing import Tuple from PIL import Image, ImageDraw, ImageFilter import numpy as np try: import cairosvg except ImportError as e: raise SystemExit("CairoSVG is required. Install with `pip install cairosvg`.\n" + str(e)) # ---------------------------- Utils ---------------------------- def clamp(v: float, a: float = 0.0, b: float = 1.0) -> float: return max(a, min(b, v)) # ---------------------------- Average color ---------------------------- def average_color_from_svg(svg_bytes: bytes, sample_size: int = 192) -> Tuple[int,int,int]: """Render the SVG small and compute the alpha-weighted average RGB of visible pixels.""" png = cairosvg.svg2png(bytestring=svg_bytes, output_width=sample_size, output_height=sample_size, background_color=None) img = Image.open(io.BytesIO(png)).convert('RGBA') arr = np.array(img).astype(np.float32) rgb = arr[..., :3] alpha = arr[..., 3:4] / 255.0 # Weight colors by alpha to ignore fully transparent pixels weighted = rgb * alpha denom = float(alpha.sum()) if float(alpha.sum()) > 0 else 1.0 mean_rgb = weighted.sum(axis=(0,1)) / denom # Guard against near-white/near-black averages by nudging slightly toward mid r,g,b = mean_rgb avg = (int(clamp(r/255.0, 0.0, 1.0)*255), int(clamp(g/255.0, 0.0, 1.0)*255), int(clamp(b/255.0, 0.0, 1.0)*255)) return avg # ---------------------------- Background generation ---------------------------- def make_linear_gradient(size: Tuple[int,int], c1: Tuple[int,int,int], c2: Tuple[int,int,int], angle_deg: float = 32) -> Image.Image: w, h = size ang = math.radians(angle_deg) ux, uy = math.cos(ang), math.sin(ang) xs = np.linspace(-0.5, 0.5, w) ys = np.linspace(-0.5, 0.5, h) X, Y = np.meshgrid(xs, ys) t = (X*ux + Y*uy - (-math.sqrt(0.5))) / (math.sqrt(0.5) - (-math.sqrt(0.5))) t = np.clip(t, 0, 1) a = np.array(c1, dtype=np.float32) b = np.array(c2, dtype=np.float32) arr = (a*(1-t)[...,None] + b*t[...,None]).astype(np.uint8) return Image.fromarray(arr) def subtle_grain(img: Image.Image, amount: float = 0.012, blur: float = 0.4) -> Image.Image: """Very subtle grain to prevent banding in gradients.""" w, h = img.size noise = np.random.normal(0.0, amount*255, (h, w, 1)).astype(np.float32) base = np.array(img).astype(np.float32) noisy = np.clip(base + noise, 0, 255).astype(np.uint8) out = Image.fromarray(noisy) if blur > 0: out = out.filter(ImageFilter.GaussianBlur(blur)) return out def derive_gradient_from_base(base: Tuple[int,int,int]) -> Tuple[Tuple[int,int,int], Tuple[int,int,int]]: """Create two harmonious colors from a base color using small hue/lightness shifts.""" import colorsys r,g,b = [c/255.0 for c in base] h, l, s = colorsys.rgb_to_hls(r, g, b) # HLS order in colorsys # Slight hue shift and lightness variation for a modern two-stop gradient h2 = (h + 0.035) % 1.0 l1 = clamp(l * 0.82, 0.0, 1.0) l2 = clamp(l * 1.15 if l < 0.75 else min(0.95, l + 0.10), 0.0, 1.0) s1 = clamp(min(1.0, s * 0.95), 0.0, 1.0) s2 = clamp(min(1.0, s * 1.05), 0.0, 1.0) r1,g1,b1 = colorsys.hls_to_rgb(h, l1, s1) r2,g2,b2 = colorsys.hls_to_rgb(h2, l2, s2) c1 = (int(r1*255), int(g1*255), int(b1*255)) c2 = (int(r2*255), int(g2*255), int(b2*255)) return c1, c2 # ---------------------------- SVG rendering ---------------------------- def render_svg(svg_bytes: bytes, target_height: int) -> Image.Image: png = cairosvg.svg2png(bytestring=svg_bytes, output_height=target_height, background_color=None) img = Image.open(io.BytesIO(png)).convert('RGBA') return img # ---------------------------- Composition ---------------------------- def compose_cover(svg_path: str, out_path: str, icon_height: int = 210, pad: int = 16) -> None: W, H = 440, 250 with open(svg_path, 'rb') as f: svg_bytes = f.read() # 1) Average color from icon avg = average_color_from_svg(svg_bytes, sample_size=192) # 2) Build gradient background from that color c1, c2 = derive_gradient_from_base(avg) bg = make_linear_gradient((W, H), c1, c2, angle_deg=32) bg = subtle_grain(bg, amount=0.010, blur=0.35) # 3) Render the icon at requested height icon = render_svg(svg_bytes, target_height=icon_height) # Fit to width if necessary max_w = W - 2*pad if icon.width > max_w: scale = max_w / icon.width icon = icon.resize((int(icon.width*scale), int(icon.height*scale)), Image.LANCZOS) # Center on canvas canvas = bg.copy().convert('RGBA') x = (W - icon.width) // 2 y = (H - icon.height) // 2 # Shape-following shadow using the icon's alpha channel alpha = icon.split()[-1] blur = 6 # softness in px dx, dy = (4, 4) # shadow offset # Blur the alpha to create penumbra and scale its opacity shadow_mask = alpha.filter(ImageFilter.GaussianBlur(blur)).point(lambda p: int(p * 0.6)) shadow = Image.new('RGBA', icon.size, (0, 0, 0, 255)) shadow.putalpha(shadow_mask) canvas.paste(shadow, (x + dx, y + dy), shadow) # Paste icon on top canvas.paste(icon, (x, y), icon) out_img = canvas.convert('RGB') out_img.save(out_path, format='PNG', optimize=True) # ---------------------------- CLI ---------------------------- def parse_args(): p = argparse.ArgumentParser(description="Generate a 440x250 BookStack cover from an SVG icon.") p.add_argument('input', help='Input SVG file') p.add_argument('output', help='Output PNG file') p.add_argument('--icon-height', type=int, default=210, help='Target icon height in pixels (default: 210)') p.add_argument('--pad', type=int, default=16, help='Padding around icon (default: 16)') return p.parse_args() def main(): args = parse_args() compose_cover(args.input, args.output, icon_height=args.icon_height, pad=args.pad) print(f"Saved cover to {args.output}") if __name__ == '__main__': main()