aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md7
-rw-r--r--converter/audio.py141
-rw-r--r--converter/converter.py44
-rw-r--r--converter/cover.py279
-rw-r--r--converter/extractors.py62
-rw-r--r--cover_test.pngbin0 -> 3938 bytes
-rw-r--r--tests/cover_test.pngbin0 -> 6285 bytes
-rw-r--r--tests/gen_test_cover.py8
-rw-r--r--tests/test_audio.py135
-rw-r--r--tests/test_cover.py189
-rw-r--r--tests/test_extractors.py67
11 files changed, 888 insertions, 44 deletions
diff --git a/README.md b/README.md
index e2e29d8..6a28641 100644
--- a/README.md
+++ b/README.md
@@ -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
new file mode 100644
index 0000000..31445c2
--- /dev/null
+++ b/cover_test.png
Binary files differ
diff --git a/tests/cover_test.png b/tests/cover_test.png
new file mode 100644
index 0000000..c6c4bc6
--- /dev/null
+++ b/tests/cover_test.png
Binary files differ
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()