aboutsummaryrefslogtreecommitdiff
path: root/app/converter/cover.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/converter/cover.py')
-rw-r--r--app/converter/cover.py279
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