aboutsummaryrefslogtreecommitdiff
path: root/converter/cover.py
diff options
context:
space:
mode:
Diffstat (limited to 'converter/cover.py')
-rw-r--r--converter/cover.py279
1 files changed, 0 insertions, 279 deletions
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