diff options
| author | historia <historiavg@proton.me> | 2026-08-18 04:05:10 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-18 04:05:10 -0400 |
| commit | 50f1825f05972e3685c55beb10c288899959b2e5 (patch) | |
| tree | a5aa104d603ce9c710499184c133abe86497a16e | |
| parent | 86d2eb8d789f82dd8e56dd0ff53933152ba94e6b (diff) | |
| download | tts-audiobook-generator-50f1825f05972e3685c55beb10c288899959b2e5.tar.gz | |
feat: add metadata to audio files including generated cover art
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | converter/audio.py | 141 | ||||
| -rw-r--r-- | converter/converter.py | 44 | ||||
| -rw-r--r-- | converter/cover.py | 279 | ||||
| -rw-r--r-- | converter/extractors.py | 62 | ||||
| -rw-r--r-- | cover_test.png | bin | 0 -> 3938 bytes | |||
| -rw-r--r-- | tests/cover_test.png | bin | 0 -> 6285 bytes | |||
| -rw-r--r-- | tests/gen_test_cover.py | 8 | ||||
| -rw-r--r-- | tests/test_audio.py | 135 | ||||
| -rw-r--r-- | tests/test_cover.py | 189 | ||||
| -rw-r--r-- | tests/test_extractors.py | 67 |
11 files changed, 888 insertions, 44 deletions
@@ -1,6 +1,8 @@ # Qwen3 Audiobook Converter -Convert TXT, PDF, and EPUB files into audiobooks using the Qwen3-TTS voice model. This builds upon [WhiskeyCoder/Qwen3-Audiobook-Converter](https://github.com/WhiskeyCoder/Qwen3-Audiobook-Converter) adding more output files, transcription/speed options, better text cleanup, and clearer instructions. It also expects the qwen-tts server to be on different ports per model, so two server processes can run at once. +Convert TXT, PDF, and EPUB files into audiobooks using the Qwen3-TTS voice model. + +This builds upon [WhiskeyCoder/Qwen3-Audiobook-Converter](https://github.com/WhiskeyCoder/Qwen3-Audiobook-Converter) adding more output files, metadata, generated cover art, transcription/speed options, better text cleanup, and clearer instructions. It also expects the qwen-tts server to be on different ports per model, so two server processes can run at once. ## Overview @@ -8,7 +10,8 @@ The converter sends text extracted from your books to a locally running Qwen3-TT - Input: `.txt`, `.pdf`, or `.epub` - Output: `.mp3`, `.m4b`, `.ogg`, or `.flac` -- Output a single mp3 or one per chapter +- Output a single file or one per chapter +- Automatic metadata (title/artist/album tags, chapter track numbers) and a generated cover - Two voice modes: - Custom voice: pre-built speakers - Voice clone: clone a voice from a `.wav` reference audio file diff --git a/converter/audio.py b/converter/audio.py index 98864f6..ebcac21 100644 --- a/converter/audio.py +++ b/converter/audio.py @@ -6,7 +6,7 @@ import shutil import subprocess import traceback from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, NamedTuple, Optional, Tuple from . import config @@ -63,6 +63,54 @@ def _encode_args(output_format: str) -> List[str]: return ["-b:a", config.AUDIO_BITRATE] +class TrackMeta(NamedTuple): + """Tags embedded into a finished audiobook file.""" + + title: str + artist: str = "" + album: str = "" + track: Optional[int] = None + total_tracks: Optional[int] = None + + +def _tag_args(meta: TrackMeta, output_format: str) -> List[str]: + """Return -metadata args plus format-specific tagging flags.""" + args = ["-metadata", f"title={meta.title}"] + if meta.artist: + args += ["-metadata", f"artist={meta.artist}"] + if meta.album: + args += ["-metadata", f"album={meta.album}"] + if meta.track and meta.total_tracks: + args += ["-metadata", f"track={meta.track}/{meta.total_tracks}"] + if output_format == "mp3": + # ID3v2.3 is what essentially every player reads; ffmpeg's default + # (v2.4) still confuses some of them. + args += ["-id3v2_version", "3"] + return args + + +# Formats whose container has no reliable embedded-picture support. +_NO_COVER_FORMATS = ("ogg", "wav") + + +def _cover_args(output_format: str, cover_input_index: int) -> List[str]: + """Return per-output args attaching a cover image as an embedded picture. + + The cover must already be added as an ffmpeg input; ``cover_input_index`` + is that input's position on the command line. mp3/flac keep the PNG + stream as-is; m4b re-encodes to JPEG, which audiobook players expect. + """ + if output_format in _NO_COVER_FORMATS: + return [] + codec = "mjpeg" if output_format == "m4b" else "copy" + args = ["-map", f"{cover_input_index}:v", "-c:v", codec, + "-disposition:v", "attached_pic", + "-metadata:s:v", "title=Album cover"] + if output_format == "m4b": + args += ["-q:v", "3"] + return args + + _brand_supported: Optional[bool] = None @@ -100,30 +148,42 @@ def _m4b_container_args() -> List[str]: def build_concat_command(concat_list: Path, output_path: Path, output_format: str, - speed: float = 1.0, speed_path: Optional[Path] = None) -> List[str]: + speed: float = 1.0, speed_path: Optional[Path] = None, + meta: Optional[TrackMeta] = None, + cover: Optional[Path] = None) -> List[str]: """Build the ffmpeg command that concatenates chunk audio into a book file.""" encode = _encode_args(output_format) container = _m4b_container_args() if output_format == "m4b" else [] + inputs = ["-f", "concat", "-safe", "0", "-i", str(concat_list)] + cover_index = None + if cover is not None and output_format not in _NO_COVER_FORMATS: + inputs += ["-i", str(cover)] + cover_index = 1 + cover_block = (_cover_args(output_format, cover_index) + if cover_index is not None else []) + tags = _tag_args(meta, output_format) if meta else [] filters = atempo_filters(speed) if filters: if speed_path is None: raise ValueError("speed_path is required when speed is not 1.0") return [ - "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), - "-filter_complex", + "ffmpeg", "-y", *inputs, "-filter_complex", f"[0:a]split=2[base][spd];[spd]{filters}[spdout]", - "-map", "[base]", *encode, *container, str(output_path), - "-map", "[spdout]", *encode, *container, str(speed_path), + "-map", "[base]", *encode, *tags, *cover_block, *container, str(output_path), + "-map", "[spdout]", *encode, *tags, *cover_block, *container, str(speed_path), ] + output_maps = ["-map", "0:a"] if cover_block else [] return [ - "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), - *encode, *container, str(output_path), + "ffmpeg", "-y", *inputs, *output_maps, + *encode, *tags, *cover_block, *container, str(output_path), ] def build_m4b_chapters_command(concat_list: Path, metadata_file: Path, output_path: Path, - speed: float = 1.0, speed_path: Optional[Path] = None, - speed_metadata_file: Optional[Path] = None) -> List[str]: + speed: float = 1.0, speed_path: Optional[Path] = None, + speed_metadata_file: Optional[Path] = None, + meta: Optional[TrackMeta] = None, + cover: Optional[Path] = None) -> List[str]: """Build the ffmpeg command that assembles chapter audio into one m4b. Chapter metadata inputs are bound to their outputs with explicit @@ -132,22 +192,30 @@ def build_m4b_chapters_command(concat_list: Path, metadata_file: Path, output_pa """ encode = _encode_args("m4b") container = _m4b_container_args() + tags = _tag_args(meta, "m4b") if meta else [] + inputs = ["-f", "concat", "-safe", "0", "-i", str(concat_list), + "-i", str(metadata_file)] + input_count = 2 # concat audio + ffmetadata + if speed_path is not None: + inputs += ["-i", str(speed_metadata_file)] + input_count += 1 + cover_block: List[str] = [] + if cover is not None: + inputs += ["-i", str(cover)] + cover_block = _cover_args("m4b", input_count) if speed_path is not None: return [ - "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), - "-i", str(metadata_file), "-i", str(speed_metadata_file), - "-filter_complex", + "ffmpeg", "-y", *inputs, "-filter_complex", f"[0:a]split=2[base][spd];[spd]{atempo_filters(speed)}[spdout]", - "-map", "[base]", "-map_metadata", "1", "-map_chapters", "1", - *encode, *container, str(output_path), - "-map", "[spdout]", "-map_metadata", "2", "-map_chapters", "2", - *encode, *container, str(speed_path), + "-map", "[base]", *cover_block, "-map_metadata", "1", "-map_chapters", "1", + *encode, *tags, *container, str(output_path), + "-map", "[spdout]", *cover_block, "-map_metadata", "2", "-map_chapters", "2", + *encode, *tags, *container, str(speed_path), ] return [ - "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), - "-i", str(metadata_file), - "-map", "0:a", "-map_metadata", "1", "-map_chapters", "1", - *encode, *container, str(output_path), + "ffmpeg", "-y", *inputs, + "-map", "0:a", *cover_block, "-map_metadata", "1", "-map_chapters", "1", + *encode, *tags, *container, str(output_path), ] @@ -215,16 +283,20 @@ def _collect_chunk_files(total_chunks: int, def combine_chunks(total_chunks: int, output_path: Path, chunk_results: Optional[Dict[int, Optional[Path]]] = None, speed: float = 1.0, output_format: str = "mp3", - intermediate: bool = False) -> bool: + intermediate: bool = False, + meta: Optional[TrackMeta] = None, + cover: Optional[Path] = None) -> bool: """Combine audio chunks into the final audiobook using ffmpeg's concat demuxer. ``chunk_results`` maps chunk numbers to the audio file each chunk produced (None for failed chunks); failed and missing chunks are skipped. When ``speed`` differs from 1.0, an additional speed-adjusted copy is written - next to the normal-speed file. Chunks are streamed by ffmpeg, so the whole - book is never held in memory. Set ``intermediate`` for scratch chapter - audio on the way to a larger output (e.g. a chaptered m4b) so save - messages don't present it as the final audiobook. + next to the normal-speed file. ``meta``/``cover`` embed tags and cover + art into the output (skipped for intermediate chapter scratch audio). + Chunks are streamed by ffmpeg, so the whole book is never held in + memory. Set ``intermediate`` for scratch chapter audio on the way to a + larger output (e.g. a chaptered m4b) so save messages don't present it + as the final audiobook. """ if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None: logger.error("ffmpeg and ffprobe are required to combine audio chunks (install ffmpeg)") @@ -249,7 +321,9 @@ def combine_chunks(total_chunks: int, output_path: Path, if atempo_filters(speed): speed_path = output_path.with_name(f"{output_path.stem}_{speed:g}{output_path.suffix}") cmd = build_concat_command(concat_list, output_path, output_format, - speed=speed, speed_path=speed_path) + speed=speed, speed_path=speed_path, + meta=None if intermediate else meta, + cover=None if intermediate else cover) proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: @@ -373,13 +447,15 @@ def build_ffmetadata(chapters: List[tuple], path: Path) -> None: def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str], - output_path: Path, speed: float = 1.0) -> bool: + output_path: Path, speed: float = 1.0, + meta: Optional[TrackMeta] = None, + cover: Optional[Path] = None) -> bool: """Concatenate per-chapter audio into a single m4b with embedded chapter markers. Chapter start/end times are derived from each chapter file's duration and - written as ffmpeg chapter metadata. When ``speed`` differs from 1.0, a - speed-adjusted copy (with rescaled chapter markers) is written alongside - the normal-speed file. + written as ffmpeg chapter metadata. ``meta``/``cover`` embed tags and + cover art. When ``speed`` differs from 1.0, a speed-adjusted copy (with + rescaled chapter markers) is written alongside the normal-speed file. """ if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None: logger.error("ffmpeg and ffprobe are required to build an m4b with chapters") @@ -413,7 +489,8 @@ def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str], cmd = build_m4b_chapters_command(concat_list, metadata_file, output_path, speed=speed, speed_path=speed_path, speed_metadata_file=speed_metadata_file - if speed_path is not None else None) + if speed_path is not None else None, + meta=meta, cover=cover) proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: diff --git a/converter/converter.py b/converter/converter.py index a58fc33..6d09e09 100644 --- a/converter/converter.py +++ b/converter/converter.py @@ -11,7 +11,8 @@ from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Tuple -from . import audio, chunking, config, extractors +from . import audio, chunking, config, cover, extractors +from .audio import TrackMeta from .tts import QwenTTSClient logger = logging.getLogger(__name__) @@ -142,31 +143,48 @@ class AudiobookConverter: audio.cleanup_chunks() logger.info("Extracting text...") - sections = extractors.extract_sections(file_path) + book = extractors.extract_book(file_path) + sections = book.sections if not sections or all(not s.text.strip() for s in sections): logger.error("No text extracted") return False stem = output_name or file_path.stem + # Cover art: generated once per book. Named with the chunk_ + # prefix so cleanup_chunks() removes it with the other scratch + # files at the end of the book. + cover_path = cover.generate_cover( + book.title, config.CHUNKS_FOLDER / "chunk_cover.png") + if cover_path: + print(f"[INFO] Generated cover art for '{book.title}'") + meta = TrackMeta(title=book.title, artist=book.author, album=book.title) + # m4b is always a single file; multi-chapter books get embedded # chapter markers so listeners can skip between chapters. if self.output_format == "m4b": if len(sections) > 1: - return self._convert_m4b_with_chapters(sections, stem, start_time) + return self._convert_m4b_with_chapters(sections, stem, start_time, + meta=meta, cover=cover_path) output_path = config.AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}" - return self._convert_text(sections[0].text, output_path, start_time) + return self._convert_text(sections[0].text, output_path, start_time, + meta=meta, cover=cover_path) if self.single_file or len(sections) == 1: text = "\n\n".join(section.text for section in sections) output_path = config.AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}" - return self._convert_text(text, output_path, start_time) + return self._convert_text(text, output_path, start_time, + meta=meta, cover=cover_path) success = True for index, section in enumerate(sections, 1): chapter_name = f"{stem}_{index:02d}_{self._sanitize_filename(section.title)}" output_path = config.AUDIOBOOKS_FOLDER / f"{chapter_name}.{self.output_format}" - success = self._convert_text(section.text, output_path, time.time()) and success + track_meta = meta._replace( + title=(section.title or "").strip() or f"Chapter {index}", + track=index, total_tracks=len(sections)) + success = self._convert_text(section.text, output_path, time.time(), + meta=track_meta, cover=cover_path) and success return success except Exception as exc: @@ -177,7 +195,9 @@ class AudiobookConverter: # Always cleanup, even on failure or interrupt audio.cleanup_chunks() - def _convert_m4b_with_chapters(self, sections, stem: str, start_time: float) -> bool: + def _convert_m4b_with_chapters(self, sections, stem: str, start_time: float, + meta: Optional[TrackMeta] = None, + cover: Optional[Path] = None) -> bool: """Convert each chapter to audio, then assemble a single m4b with embedded chapter markers. @@ -208,7 +228,8 @@ class AudiobookConverter: return False output_path = config.AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}" - if not audio.combine_chapters_to_m4b(chapter_files, titles, output_path, speed=self.speed): + if not audio.combine_chapters_to_m4b(chapter_files, titles, output_path, speed=self.speed, + meta=meta, cover=cover): return False duration = time.time() - start_time logger.info("Conversion completed in %dm %ds: %s", @@ -256,7 +277,9 @@ class AudiobookConverter: def _convert_text(self, text: str, output_path: Path, start_time: float, speed: Optional[float] = None, output_format: Optional[str] = None, - chapter: Optional[Tuple[int, int]] = None) -> bool: + chapter: Optional[Tuple[int, int]] = None, + meta: Optional[TrackMeta] = None, + cover: Optional[Path] = None) -> bool: """Chunk, synthesize, and assemble ``text`` into ``output_path``. When ``chapter`` (a ``(number, total)`` pair) is given, the output is @@ -303,7 +326,8 @@ class AudiobookConverter: # Combine chunks (only the successful ones) success = audio.combine_chunks(total_chunks, output_path, chunk_results=results, speed=speed, output_format=output_format, - intermediate=chapter is not None) + intermediate=chapter is not None, + meta=meta, cover=cover) if success: duration = time.time() - start_time diff --git a/converter/cover.py b/converter/cover.py new file mode 100644 index 0000000..b2d3cb5 --- /dev/null +++ b/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 diff --git a/converter/extractors.py b/converter/extractors.py index cd270a1..a564333 100644 --- a/converter/extractors.py +++ b/converter/extractors.py @@ -24,6 +24,14 @@ class Section(NamedTuple): text: str +class Book(NamedTuple): + """A book's metadata plus its titled sections.""" + + title: str + author: str + sections: List[Section] + + def extract_text(file_path: Path) -> str: """Extract text from a book file based on its extension.""" extension = file_path.suffix.lower() @@ -52,6 +60,60 @@ def extract_sections(file_path: Path) -> List[Section]: return [Section(file_path.stem, extract_text(file_path))] +def extract_book(file_path: Path) -> Book: + """Extract sections plus book-level metadata (title, author). + + EPUB and PDF files carry embedded metadata; missing fields (and TXT + files, which have none) fall back to the file stem for the title and + an empty author. + """ + title, author = "", "" + extension = file_path.suffix.lower() + if extension == ".epub": + title, author = _epub_metadata(file_path) + elif extension == ".pdf": + title, author = _pdf_metadata(file_path) + return Book(title or file_path.stem, author.strip(), extract_sections(file_path)) + + +def _epub_metadata(file_path: Path) -> tuple: + """Return (title, author) from an EPUB's Dublin Core metadata.""" + try: + import ebooklib + from ebooklib import epub + + book = epub.read_epub(str(file_path)) + title = _first_dc_value(book.get_metadata("DC", "title")) + author = _first_dc_value(book.get_metadata("DC", "creator")) + return title, author + except Exception as exc: + logger.warning("Could not read EPUB metadata: %s", exc) + return "", "" + + +def _pdf_metadata(file_path: Path) -> tuple: + """Return (title, author) from a PDF's document info dictionary.""" + try: + from pypdf import PdfReader + + reader = PdfReader(str(file_path)) + info = reader.metadata or {} + title = str(info.get("/Title") or "") + author = str(info.get("/Author") or "") + return title, author + except Exception as exc: + logger.warning("Could not read PDF metadata: %s", exc) + return "", "" + + +def _first_dc_value(entries) -> str: + """First value of an ebooklib DC metadata list: [(value, ...), ...].""" + if not entries: + return "" + value = entries[0][0] + return str(value).strip() if value else "" + + def _extract_epub_chapters(file_path: Path) -> List[Section]: """Return one Section per EPUB spine document (chapter), in reading order.""" import ebooklib diff --git a/cover_test.png b/cover_test.png Binary files differnew file mode 100644 index 0000000..31445c2 --- /dev/null +++ b/cover_test.png diff --git a/tests/cover_test.png b/tests/cover_test.png Binary files differnew file mode 100644 index 0000000..c6c4bc6 --- /dev/null +++ b/tests/cover_test.png diff --git a/tests/gen_test_cover.py b/tests/gen_test_cover.py new file mode 100644 index 0000000..7c9347e --- /dev/null +++ b/tests/gen_test_cover.py @@ -0,0 +1,8 @@ +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from converter.cover import generate_cover + +p = generate_cover('Your Book Title Here', + Path(__file__).resolve().parent / 'cover_test.png') +print('written:', p) diff --git a/tests/test_audio.py b/tests/test_audio.py index 7116e2b..97280ae 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -8,8 +8,11 @@ from pathlib import Path from converter import audio from converter import config from converter.audio import ( + TrackMeta, _collect_chunk_files, + _cover_args, _encode_args, + _tag_args, build_concat_command, build_ffmetadata, build_m4b_chapters_command, @@ -287,5 +290,137 @@ class BuildFFMetadataTests(unittest.TestCase): self.assertNotIn("title=Two\n", content) +class TagArgsTests(unittest.TestCase): + META = TrackMeta(title="Dune", artist="Frank Herbert", album="Dune", + track=2, total_tracks=5) + + def test_full_meta_written(self): + args = _tag_args(self.META, "mp3") + for pair in ("title=Dune", "artist=Frank Herbert", + "album=Dune", "track=2/5"): + self.assertIn(pair, args) + + def test_mp3_gets_id3v23(self): + mp3_args = _tag_args(self.META, "mp3") + self.assertIn("-id3v2_version", mp3_args) + self.assertEqual(mp3_args[mp3_args.index("-id3v2_version") + 1], "3") + self.assertNotIn("-id3v2_version", _tag_args(self.META, "flac")) + + def test_empty_fields_omitted(self): + meta = TrackMeta(title="Only Title") + args = _tag_args(meta, "flac") + self.assertNotIn("artist", args) + self.assertNotIn("album", args) + self.assertNotIn("track", args) + + def test_track_requires_total(self): + meta = TrackMeta(title="T", track=3) + self.assertNotIn("track", _tag_args(meta, "mp3")) + + +class CoverArgsTests(unittest.TestCase): + def test_mp3_copies_png_stream(self): + args = _cover_args("mp3", 1) + self.assertIn("copy", args) + self.assertIn("attached_pic", args) + self.assertIn("1:v", args) + + def test_m4b_reencodes_to_jpeg(self): + args = _cover_args("m4b", 2) + self.assertIn("mjpeg", args) + self.assertIn("attached_pic", args) + self.assertIn("3", args) # jpeg quality + + def test_ogg_and_wav_have_no_cover(self): + self.assertEqual(_cover_args("ogg", 1), []) + self.assertEqual(_cover_args("wav", 1), []) + + +class BuildConcatCommandMetaTests(unittest.TestCase): + META = TrackMeta(title="Chapter 1", artist="Author", album="Book", + track=1, total_tracks=3) + + def test_cover_added_as_second_input(self): + cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3", + meta=self.META, cover=Path("cover.png")) + # The cover is the second input, after the concat list + self.assertIn("cover.png", cmd) + self.assertLess(cmd.index("list.txt"), cmd.index("cover.png")) + self.assertIn("-map", cmd) + self.assertIn("1:v", cmd) + self.assertIn("attached_pic", cmd) + self.assertEqual(cmd[-1], "out.mp3") + + def test_audio_explicitly_mapped_when_cover_present(self): + cmd = build_concat_command(Path("list.txt"), Path("out.flac"), "flac", + cover=Path("cover.png")) + self.assertIn("0:a", cmd) + + def test_no_cover_keeps_single_input(self): + cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3", + meta=self.META) + self.assertEqual(cmd.count("-i"), 1) + self.assertNotIn("attached_pic", cmd) + + def test_ogg_never_gets_cover_input(self): + cmd = build_concat_command(Path("list.txt"), Path("out.ogg"), "ogg", + meta=self.META, cover=Path("cover.png")) + self.assertEqual(cmd.count("-i"), 1) + self.assertNotIn("attached_pic", cmd) + + def test_speed_copy_gets_tags_and_cover(self): + cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3", + speed=1.5, speed_path=Path("out_1.5.mp3"), + meta=self.META, cover=Path("cover.png")) + self.assertEqual(cmd.count("attached_pic"), 2) + self.assertEqual(cmd.count("title=Chapter 1"), 2) + self.assertEqual(cmd.count("1:v"), 2) + + def test_tags_without_cover_present(self): + cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3", + meta=self.META) + self.assertIn("title=Chapter 1", cmd) + self.assertIn("artist=Author", cmd) + self.assertIn("album=Book", cmd) + self.assertIn("track=1/3", cmd) + + +class BuildM4bChaptersCommandMetaTests(unittest.TestCase): + def setUp(self): + self._original = audio._brand_supported + audio._brand_supported = True + + def tearDown(self): + audio._brand_supported = self._original + + def test_cover_indexed_after_metadata_inputs(self): + cmd = build_m4b_chapters_command(Path("list.txt"), Path("meta.txt"), + Path("out.m4b"), cover=Path("cover.png")) + # Inputs: 0=audio, 1=ffmetadata, 2=cover + self.assertIn("-i", cmd) + self.assertIn("2:v", cmd) + self.assertIn("attached_pic", cmd) + + def test_speed_variant_cover_is_input_three(self): + cmd = build_m4b_chapters_command( + Path("list.txt"), Path("meta.txt"), Path("out.m4b"), + speed=2.0, speed_path=Path("out_2.m4b"), speed_metadata_file=Path("meta2.txt"), + meta=TrackMeta(title="Book"), cover=Path("cover.png"), + ) + self.assertEqual(cmd.count("3:v"), 2) # both outputs attach the cover + self.assertNotIn("2:v", cmd) + self.assertEqual(cmd.count("title=Book"), 2) + # Chapter metadata inputs keep their 1/2 mapping + chapter_flags = [i for i, v in enumerate(cmd) if v == "-map_chapters"] + self.assertEqual(cmd[chapter_flags[0] + 1], "1") + self.assertEqual(cmd[chapter_flags[1] + 1], "2") + + def test_without_cover_regression(self): + cmd = build_m4b_chapters_command(Path("list.txt"), Path("meta.txt"), + Path("out.m4b")) + self.assertNotIn("attached_pic", cmd) + self.assertNotIn("-metadata", cmd) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cover.py b/tests/test_cover.py new file mode 100644 index 0000000..f19db5a --- /dev/null +++ b/tests/test_cover.py @@ -0,0 +1,189 @@ +"""Tests for stdlib-only cover generation: PNG structure, gradient, text.""" + +import random +import struct +import tempfile +import unittest +import zlib +from pathlib import Path + +from converter.cover import ( + _random_light_color, + _text_width, + _wrap_title, + generate_cover, +) + + +def _decode_png(data: bytes): + """Parse a PNG into (width, height, rows of RGB tuples).""" + assert data[:8] == b"\x89PNG\r\n\x1a\n", "bad PNG signature" + pos = 8 + idat = b"" + width = height = None + while pos < len(data): + length, chunk_type = struct.unpack(">I4s", data[pos:pos + 8]) + chunk_data = data[pos + 8:pos + 8 + length] + crc = struct.unpack(">I", data[pos + 8 + length:pos + 12 + length])[0] + assert crc == zlib.crc32(chunk_type + chunk_data) & 0xFFFFFFFF, "bad CRC" + if chunk_type == b"IHDR": + width, height, depth, color_type = struct.unpack(">IIBB", chunk_data[:10]) + assert depth == 8 and color_type == 2 # 8-bit RGB + elif chunk_type == b"IDAT": + idat += chunk_data + pos += 12 + length + raw = zlib.decompress(idat) + stride = 1 + width * 3 + assert len(raw) == height * stride, "unexpected decompressed size" + rows = [] + for y in range(height): + row = raw[y * stride + 1:(y + 1) * stride] + rows.append([tuple(row[x * 3:x * 3 + 3]) for x in range(width)]) + return width, height, rows + + +def _black_pixels(rows): + return sum(1 for row in rows for pixel in row if pixel == (0, 0, 0)) + + +class GenerateCoverTests(unittest.TestCase): + def _write(self, title, width=120, height=180, seed=7): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "cover.png" + result = generate_cover(title, path, width=width, height=height, seed=seed) + data = path.read_bytes() + return result, data + + def test_valid_png_with_requested_dimensions(self): + _, data = self._write("Hello") + width, height, rows = _decode_png(data) + self.assertEqual((width, height), (120, 180)) + self.assertEqual(len(rows), 180) + + def test_gradient_matches_seeded_colors(self): + _, data = self._write("Hello", seed=42) + width, height, rows = _decode_png(data) + rng = random.Random(42) + top = _random_light_color(rng) + bottom = _random_light_color(rng) + # Corners of the text-free top/bottom rows match the endpoints + self.assertEqual(rows[0][0], top) + self.assertEqual(rows[0][-1], top) + self.assertEqual(rows[height - 1][0], bottom) + self.assertEqual(rows[height - 1][-1], bottom) + + def test_gradient_colors_are_light(self): + # Text-free bottom row: every channel must stay in pastel territory + _, data = self._write("Hello", seed=1) + _, height, rows = _decode_png(data) + for channel in rows[height - 1][0]: + self.assertGreaterEqual(channel, 90) + + def test_title_renders_black_pixels(self): + _, data = self._write("Hello") + _, _, rows = _decode_png(data) + self.assertGreater(_black_pixels(rows), 50) + + def test_title_renders_white_pixels(self): + _, data = self._write("Hello") + _, _, rows = _decode_png(data) + white = sum(1 for row in rows for pixel in row if pixel == (255, 255, 255)) + self.assertGreater(white, 50) + + def test_white_text_sits_on_black_stroke(self): + # Directly above a white pixel row there must be a black stroke row: + # sample white pixels and confirm black neighbors within stroke width. + _, data = self._write("Hi", width=200, height=100, seed=3) + _, _, rows = _decode_png(data) + whites = [(x, y) for y, row in enumerate(rows) + for x, pixel in enumerate(row) if pixel == (255, 255, 255)] + self.assertTrue(whites) + checked = near_stroke = 0 + for x, y in whites[::5]: + neighborhood = [] + for dy in range(-3, 4): + for dx in range(-3, 4): + if 0 <= y + dy < len(rows) and 0 <= x + dx < len(rows[0]): + neighborhood.append(rows[y + dy][x + dx]) + checked += 1 + if (0, 0, 0) in neighborhood: + near_stroke += 1 + # Interior white pixels are surrounded by white; every sampled pixel + # should still see stroke black within 3px (font strokes are 5-6 px thick) + self.assertEqual(near_stroke, checked) + + def test_empty_title_renders_gradient_only(self): + _, data = self._write("") + _, _, rows = _decode_png(data) + self.assertEqual(_black_pixels(rows), 0) + + def test_unrenderable_title_degrades_to_gradient(self): + # CJK glyphs are not in the bitmap font; no crash, no text pixels + _, data = self._write("δΉ¦ε") + _, _, rows = _decode_png(data) + self.assertEqual(_black_pixels(rows), 0) + + def test_write_failure_returns_none(self): + result = generate_cover("Hello", Path("/nonexistent_dir/cover.png")) + self.assertIsNone(result) + + +class WrapTitleTests(unittest.TestCase): + def test_short_title_one_line(self): + self.assertEqual(len(_wrap_title("Dune", 500)), 1) + + def test_long_title_wraps(self): + lines = _wrap_title("The Extremely Long Windy Title of a Very Long Book", 600) + self.assertGreater(len(lines), 1) + for line in lines: + self.assertLessEqual(_text_width(line), 600) + + def test_single_long_word_kept_intact(self): + lines = _wrap_title("Antidisestablishmentarianism", 10) + self.assertEqual(lines, ["Antidisestablishmentarianism"]) + + def test_empty_title_no_lines(self): + self.assertEqual(_wrap_title("", 500), []) + + +class TextWidthTests(unittest.TestCase): + def test_empty(self): + self.assertEqual(_text_width(""), 0) + + def test_single_char_is_scaled_glyph(self): + self.assertEqual(_text_width("A"), 30) # 5 px * scale 6 + + def test_chars_include_spacing(self): + self.assertEqual(_text_width("AB"), 66) # (2 glyphs * 6 - 1) * 6 + + +class DropShadowTests(unittest.TestCase): + def _cover_rows(self, title, seed=7): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "cover.png" + generate_cover(title, path, width=200, height=200, seed=seed) + return _decode_png(path.read_bytes())[2] + + def test_shadow_pixels_survive_next_to_text(self): + rows = self._cover_rows("Hi") + # The shadow lives down-right of the glyphs: there must be darkened + # (but not pure black, not full-brightness) pixels beyond the text + # block's bottom edge. + blacks = {(x, y) for y, row in enumerate(rows) + for x, pixel in enumerate(row) if pixel == (0, 0, 0)} + self.assertTrue(blacks, "no text rendered") + text_bottom = max(y for _, y in blacks) + darkened = [pixel for y, row in enumerate(rows) + if y > text_bottom for pixel in row + if pixel != (0, 0, 0) and max(pixel) < 130] + self.assertTrue(darkened, "no shadow pixels below the text") + + def test_empty_title_has_no_shadow(self): + rows = self._cover_rows("") + for row in rows: + for pixel in row: + self.assertNotEqual(pixel, (0, 0, 0)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_extractors.py b/tests/test_extractors.py index 7b307c0..d604351 100644 --- a/tests/test_extractors.py +++ b/tests/test_extractors.py @@ -43,6 +43,7 @@ def _build_test_epub(path: Path, chapters=(("One", "First chapter text."), book.set_identifier("test-id") book.set_title("Test Book") book.set_language("en") + book.add_author("Test Author") items = [] for index, (title, text) in enumerate(chapters, 1): @@ -138,5 +139,71 @@ class ExtractSectionsTests(unittest.TestCase): self.assertIn("Just one chapter.", sections[0].text) +class ExtractBookTests(unittest.TestCase): + def test_txt_falls_back_to_stem_and_blank_author(self): + from converter.extractors import extract_book + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "mybook.txt" + path.write_text("Hello world.", encoding="utf-8") + book = extract_book(path) + + self.assertEqual(book.title, "mybook") + self.assertEqual(book.author, "") + self.assertEqual(len(book.sections), 1) + + def test_epub_metadata_harvested(self): + from converter.extractors import extract_book + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "book.epub" + _build_test_epub(path) + book = extract_book(path) + + self.assertEqual(book.title, "Test Book") + self.assertEqual(book.author, "Test Author") + self.assertEqual([s.title for s in book.sections], ["One", "Two"]) + + def test_pdf_metadata_harvested(self): + from converter.extractors import extract_book + + try: + from pypdf import PdfWriter + except ImportError: + self.skipTest("pypdf not installed") + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "book.pdf" + writer = PdfWriter() + writer.add_metadata({"/Title": "PDF Title", "/Author": "PDF Author"}) + writer.add_blank_page(width=612, height=792) + with open(path, "wb") as handle: + writer.write(handle) + book = extract_book(path) + + self.assertEqual(book.title, "PDF Title") + self.assertEqual(book.author, "PDF Author") + self.assertEqual(len(book.sections), 1) + + def test_pdf_without_metadata_falls_back(self): + from converter.extractors import extract_book + + try: + from pypdf import PdfWriter + except ImportError: + self.skipTest("pypdf not installed") + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "plain.pdf" + writer = PdfWriter() + writer.add_blank_page(width=612, height=792) + with open(path, "wb") as handle: + writer.write(handle) + book = extract_book(path) + + self.assertEqual(book.title, "plain") + self.assertEqual(book.author, "") + + if __name__ == "__main__": unittest.main() |
