From f00249db9d1ea051d29aa1bcca869fc4b88e83eb Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 02:59:26 -0400 Subject: refactor: add app directory, dir structure change --- converter/cover.py | 279 ----------------------------------------------------- 1 file changed, 279 deletions(-) delete mode 100644 converter/cover.py (limited to 'converter/cover.py') diff --git a/converter/cover.py b/converter/cover.py deleted file mode 100644 index b2d3cb5..0000000 --- a/converter/cover.py +++ /dev/null @@ -1,279 +0,0 @@ -"""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 -- cgit v1.2.3