diff options
| author | historia <historiavg@proton.me> | 2026-08-24 02:59:26 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 02:59:26 -0400 |
| commit | f00249db9d1ea051d29aa1bcca869fc4b88e83eb (patch) | |
| tree | a75f076fac1b63e0b4bf2eb8f54affbcc681a891 /app/converter/cover.py | |
| parent | 9dd4f9595be3b1d76a3a07dc3eca90cfaf8a3f97 (diff) | |
| download | tts-audiobook-generator-f00249db9d1ea051d29aa1bcca869fc4b88e83eb.tar.gz | |
refactor: add app directory, dir structure change
Diffstat (limited to 'app/converter/cover.py')
| -rw-r--r-- | app/converter/cover.py | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/app/converter/cover.py b/app/converter/cover.py new file mode 100644 index 0000000..b2d3cb5 --- /dev/null +++ b/app/converter/cover.py @@ -0,0 +1,279 @@ +"""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 |
