"""Book cover generation: stdlib-only PNG with gradient + title text. Covers are a vertical gradient between two random light colors with the book title rendered on top in white with a black outline and a drop shadow, using an embedded 5x7 bitmap font. No third-party image libraries are required: PNG scanlines are packed and compressed with zlib/struct directly. """ import logging import random import struct import zlib from colorsys import hsv_to_rgb from pathlib import Path from typing import List, Optional, Tuple logger = logging.getLogger(__name__) COVER_WIDTH = 600 COVER_HEIGHT = 900 # 5x7 bitmap font for ASCII 32..126. Each glyph is 7 rows of 5 bits # (MSB left), encoded as ints for compactness. _FONT = { " ": [0, 0, 0, 0, 0, 0, 0], "!": [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04], '"': [0x0A, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00], "#": [0x0A, 0x0A, 0x1F, 0x0A, 0x1F, 0x0A, 0x0A], "$": [0x04, 0x0F, 0x14, 0x0E, 0x05, 0x1E, 0x04], "%": [0x18, 0x19, 0x02, 0x04, 0x08, 0x13, 0x03], "&": [0x08, 0x14, 0x14, 0x08, 0x15, 0x12, 0x0D], "'": [0x04, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00], "(": [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02], ")": [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08], "*": [0x00, 0x04, 0x15, 0x0E, 0x15, 0x04, 0x00], "+": [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00], ",": [0x00, 0x00, 0x00, 0x00, 0x04, 0x04, 0x08], "-": [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00], ".": [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C], "/": [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x00], "0": [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E], "1": [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E], "2": [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F], "3": [0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E], "4": [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02], "5": [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E], "6": [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E], "7": [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], "8": [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E], "9": [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C], ":": [0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00], ";": [0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x04, 0x08], "<": [0x02, 0x04, 0x08, 0x10, 0x08, 0x04, 0x02], "=": [0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00], ">": [0x08, 0x04, 0x02, 0x01, 0x02, 0x04, 0x08], "?": [0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04], "@": [0x0E, 0x11, 0x17, 0x15, 0x17, 0x10, 0x0E], "A": [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11], "B": [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E], "C": [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E], "D": [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C], "E": [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F], "F": [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10], "G": [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F], "H": [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11], "I": [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E], "J": [0x01, 0x01, 0x01, 0x01, 0x01, 0x11, 0x0E], "K": [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], "L": [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F], "M": [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11], "N": [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11], "O": [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E], "P": [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10], "Q": [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D], "R": [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11], "S": [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E], "T": [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], "U": [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E], "V": [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04], "W": [0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0A], "X": [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11], "Y": [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04], "Z": [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F], "[": [0x0E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0E], "\\": [0x00, 0x10, 0x08, 0x04, 0x02, 0x01, 0x00], "]": [0x0E, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0E], "^": [0x04, 0x0A, 0x11, 0x00, 0x00, 0x00, 0x00], "_": [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F], "`": [0x08, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00], "a": [0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F], "b": [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x1E], "c": [0x00, 0x00, 0x0F, 0x10, 0x10, 0x10, 0x0F], "d": [0x01, 0x01, 0x0F, 0x11, 0x11, 0x11, 0x0F], "e": [0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E], "f": [0x06, 0x08, 0x1E, 0x08, 0x08, 0x08, 0x08], "g": [0x00, 0x0F, 0x11, 0x11, 0x0F, 0x01, 0x1E], "h": [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x11], "i": [0x04, 0x00, 0x0C, 0x04, 0x04, 0x04, 0x0E], "j": [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C], "k": [0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12], "l": [0x0C, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E], "m": [0x00, 0x00, 0x1A, 0x15, 0x15, 0x15, 0x15], "n": [0x00, 0x00, 0x1E, 0x11, 0x11, 0x11, 0x11], "o": [0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E], "p": [0x00, 0x00, 0x1E, 0x11, 0x11, 0x1E, 0x10], "q": [0x00, 0x00, 0x0F, 0x11, 0x11, 0x0F, 0x01], "r": [0x00, 0x00, 0x16, 0x09, 0x08, 0x08, 0x08], "s": [0x00, 0x00, 0x0F, 0x10, 0x0E, 0x01, 0x1E], "t": [0x08, 0x08, 0x1E, 0x08, 0x08, 0x08, 0x06], "u": [0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0D], "v": [0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04], "w": [0x00, 0x00, 0x11, 0x15, 0x15, 0x15, 0x0A], "x": [0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11], "y": [0x00, 0x00, 0x11, 0x11, 0x0F, 0x01, 0x1E], "z": [0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F], "{": [0x06, 0x08, 0x08, 0x04, 0x08, 0x08, 0x06], "|": [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], "}": [0x0C, 0x02, 0x02, 0x04, 0x02, 0x02, 0x0C], "~": [0x00, 0x00, 0x08, 0x15, 0x02, 0x00, 0x00], } _GLYPH_WIDTH = 5 _GLYPH_HEIGHT = 7 _TEXT_SCALE = 6 # render each font pixel as a 6x6 block _TEXT_MARGIN = 60 # horizontal padding when wrapping _TEXT_COLOR = (255, 255, 255) _STROKE_COLOR = (0, 0, 0) _STROKE_WIDTH = 3 # outline thickness in pixels _SHADOW_OFFSET = 4 # drop shadow shift in pixels (down-right) _SHADOW_DARKEN = 0.55 # shadow keeps 55% of the background color def _random_light_color(rng: random.Random) -> Tuple[int, int, int]: """A random pastel: any hue, low saturation, high value.""" hue = rng.random() saturation = rng.uniform(0.25, 0.55) value = rng.uniform(0.82, 0.95) r, g, b = hsv_to_rgb(hue, saturation, value) return int(r * 255), int(g * 255), int(b * 255) def _lerp(a: int, b: int, t: float) -> int: return int(round(a + (b - a) * t)) def _text_width(text: str) -> int: """Pixel width of ``text`` at the rendered scale (spaces count too).""" if not text: return 0 return (len(text) * (_GLYPH_WIDTH + 1) - 1) * _TEXT_SCALE def _wrap_title(title: str, max_width: int) -> List[str]: """Word-wrap ``title`` into lines that fit ``max_width`` pixels.""" words = title.split() if not words: return [] lines: List[str] = [] current = "" for word in words: candidate = f"{current} {word}".strip() if _text_width(candidate) <= max_width or not current: current = candidate else: lines.append(current) current = word if current: lines.append(current) return lines def _set_pixel(pixels: List[List[Tuple[int, int, int]]], x: int, y: int, color: Tuple[int, int, int], darken: Optional[float] = None) -> None: """Set one pixel: solid ``color``, or darken the existing pixel by ``darken``.""" if not 0 <= y < len(pixels): return if not 0 <= x < len(pixels[y]): return if darken is None: pixels[y][x] = color else: r, g, b = pixels[y][x] pixels[y][x] = (int(r * darken), int(g * darken), int(b * darken)) def _render_line(pixels: List[List[Tuple[int, int, int]]], text: str, x0: int, y0: int, color: Tuple[int, int, int] = (0, 0, 0), darken: Optional[float] = None) -> None: """Blit one line of bitmap text onto the pixel grid in place. With ``darken``, each glyph pixel darkens whatever is underneath it instead of painting a solid color (used for the drop shadow, which stays tinted by the gradient behind it). """ for char_index, char in enumerate(text): glyph = _FONT.get(char) if glyph is None: continue x_off = x0 + char_index * (_GLYPH_WIDTH + 1) * _TEXT_SCALE for gy, bits in enumerate(glyph): for gx in range(_GLYPH_WIDTH): if not bits & (1 << (_GLYPH_WIDTH - 1 - gx)): continue for dy in range(_TEXT_SCALE): for dx in range(_TEXT_SCALE): _set_pixel(pixels, x_off + gx * _TEXT_SCALE + dx, y0 + gy * _TEXT_SCALE + dy, color, darken) def _encode_png(width: int, height: int, pixels: List[List[Tuple[int, int, int]]]) -> bytes: """Encode an RGB pixel grid as a PNG using only the stdlib.""" def chunk(chunk_type: bytes, data: bytes) -> bytes: payload = chunk_type + data return (struct.pack(">I", len(data)) + payload + struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF)) raw = b"".join( b"\x00" + bytes(channel for pixel in scanline for channel in pixel) for scanline in pixels ) ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8-bit RGB return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) def generate_cover(title: str, path: Path, width: int = COVER_WIDTH, height: int = COVER_HEIGHT, seed: Optional[int] = None) -> Optional[Path]: """Write a gradient cover PNG with ``title`` centered on it. ``seed`` makes the gradient reproducible (used by tests). Returns the path on success, or None when the PNG cannot be written (the audiobook still gets tags, just without cover art). """ rng = random.Random(seed) top_color = _random_light_color(rng) bottom_color = _random_light_color(rng) pixels = [] for y in range(height): t = y / max(1, height - 1) color = (_lerp(top_color[0], bottom_color[0], t), _lerp(top_color[1], bottom_color[1], t), _lerp(top_color[2], bottom_color[2], t)) pixels.append([color] * width) lines = _wrap_title((title or "").strip(), width - 2 * _TEXT_MARGIN) if lines: line_height = _GLYPH_HEIGHT * _TEXT_SCALE + _TEXT_SCALE * 3 total_height = len(lines) * line_height y_start = max(0, (height - total_height) // 2) for line_index, line in enumerate(lines): x0 = (width - _text_width(line)) // 2 y0 = y_start + line_index * line_height # Paint order: drop shadow (the outlined glyph's full silhouette # — glyph dilated by the stroke width — shifted down-right and # darkened, tinted by the gradient underneath), then the black # outline traced around the glyph, then the solid white text. for dx in range(-_STROKE_WIDTH, _STROKE_WIDTH + 1): for dy in range(-_STROKE_WIDTH, _STROKE_WIDTH + 1): _render_line(pixels, line, x0 + dx + _SHADOW_OFFSET, y0 + dy + _SHADOW_OFFSET, darken=_SHADOW_DARKEN) for dx in range(-_STROKE_WIDTH, _STROKE_WIDTH + 1): for dy in range(-_STROKE_WIDTH, _STROKE_WIDTH + 1): _render_line(pixels, line, x0 + dx, y0 + dy, color=_STROKE_COLOR) _render_line(pixels, line, x0, y0, color=_TEXT_COLOR) try: path.write_bytes(_encode_png(width, height, pixels)) except OSError as exc: logger.warning("Could not write cover image %s: %s", path, exc) return None logger.info("Generated cover: %s (gradient %s -> %s)", path, top_color, bottom_color) return path