diff --git a/abogen/epub3/exporter.py b/abogen/epub3/exporter.py index 2fdc8d4..999c917 100644 --- a/abogen/epub3/exporter.py +++ b/abogen/epub3/exporter.py @@ -24,6 +24,7 @@ class ChunkOverlay: speaker_id: str voice: Optional[str] level: Optional[str] = None + group_id: Optional[str] = None @dataclass(slots=True) @@ -256,6 +257,10 @@ class EPUB3PackageBuilder: normalized_id = f"{normalized_id}_dup" used_ids.add(normalized_id) + raw_group_key = chunk_entry.get("id") if chunk_entry else chunk_id + group_id = _derive_group_id(raw_group_key, level) + normalized_group_id = _normalize_chunk_id(group_id) if group_id else None + overlays.append( ChunkOverlay( id=normalized_id, @@ -266,6 +271,7 @@ class EPUB3PackageBuilder: speaker_id=speaker_id, voice=str(voice) if voice else None, level=str(level) if level else None, + group_id=normalized_group_id, ) ) @@ -281,7 +287,10 @@ class EPUB3PackageBuilder: def _render_chapter_xhtml(self, chapter: ChapterDocument) -> str: language = html.escape(self._language or "en") title = html.escape(chapter.title) - chunk_html = "\n".join(_render_chunk_html(chunk) for chunk in chapter.chunks) + grouped_chunks = _group_chunks_for_render(chapter.chunks) + chunk_html = "\n".join( + _render_chunk_group_html(group_id, items) for group_id, items in grouped_chunks + ) if not chunk_html: chunk_html = "

" original_block = "" @@ -647,7 +656,40 @@ def _normalize_chunk_id(chunk_id: Optional[Any]) -> Optional[str]: return safe[:120] -def _render_chunk_html(chunk: ChunkOverlay) -> str: +def _derive_group_id(chunk_id: Optional[Any], level: Optional[Any]) -> Optional[str]: + if chunk_id is None: + return None + text = str(chunk_id).strip() + if not text: + return None + if str(level or "").lower() == "sentence": + match = re.match(r"(.+?)_s\d+(?:_.*)?$", text) + if match: + return match.group(1) + return text + + +def _group_chunks_for_render(chunks: Sequence[ChunkOverlay]) -> List[Tuple[Optional[str], List[ChunkOverlay]]]: + groups: List[Tuple[Optional[str], List[ChunkOverlay]]] = [] + current_key: Optional[str] = None + current_items: List[ChunkOverlay] = [] + + for chunk in chunks: + key = chunk.group_id or chunk.id + if current_items and key != current_key: + groups.append((current_key, current_items)) + current_items = [] + if not current_items: + current_key = key + current_items.append(chunk) + + if current_items: + groups.append((current_key, current_items)) + + return groups + + +def _render_chunk_inline(chunk: ChunkOverlay) -> str: escaped_id = html.escape(chunk.id) speaker_attr = f" data-speaker=\"{html.escape(chunk.speaker_id)}\"" if chunk.speaker_id else "" voice_attr = f" data-voice=\"{html.escape(chunk.voice)}\"" if chunk.voice else "" @@ -657,12 +699,22 @@ def _render_chunk_html(chunk: ChunkOverlay) -> str: if not escaped_text: escaped_text = " " return ( - f"
" + f"" f"{escaped_text}" - "
" + "" ) +def _render_chunk_group_html(group_id: Optional[str], chunks: Sequence[ChunkOverlay]) -> str: + if not chunks: + return "" + group_attr = f" data-group=\"{html.escape(group_id)}\"" if group_id else "" + inline_html = "".join(_render_chunk_inline(chunk) for chunk in chunks) + if not inline_html: + inline_html = " " + return f"

{inline_html}

" + + def _format_smil_time(value: Optional[float]) -> str: if value is None or value < 0: value = 0.0 @@ -840,20 +892,11 @@ h1 { margin-bottom: 0.5em; } -div.chunk { - margin: 0 0 1em 0; +.chunk-group { + margin: 0.5em 0; +} + +.chunk-group .chunk { white-space: pre-wrap; } - -div.chunk[data-level="sentence"] { - margin-bottom: 0.5em; -} - -div.chunk[data-level="sentence"] + div.chunk[data-level="sentence"] { - margin-top: -0.2em; -} - -p { - margin: 0.5em 0; -} """ diff --git a/abogen/voice_cache.py b/abogen/voice_cache.py index 9642a9f..69bd1be 100644 --- a/abogen/voice_cache.py +++ b/abogen/voice_cache.py @@ -10,6 +10,10 @@ except Exception: # pragma: no cover - import fallback hf_hub_download = None # type: ignore[assignment] LocalEntryNotFoundError = None # type: ignore[assignment] +if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests + class LocalEntryNotFoundError(Exception): + pass + from abogen.constants import VOICES_INTERNAL _CACHE_LOCK = threading.Lock() @@ -116,11 +120,18 @@ def _ensure_single_voice_asset( raise RuntimeError("huggingface_hub is required to cache voices") filename = f"voices/{voice_id}.pt" + common_kwargs = { + "repo_id": repo_id, + "filename": filename, + } + if cache_dir is not None: + common_kwargs["cache_dir"] = cache_dir - hf_hub_download( - repo_id=repo_id, - filename=filename, - cache_dir=cache_dir, - resume_download=True, - ) + try: + hf_hub_download(local_files_only=True, **common_kwargs) + return False + except LocalEntryNotFoundError: + pass + + hf_hub_download(resume_download=True, **common_kwargs) return True \ No newline at end of file diff --git a/tests/test_epub_exporter.py b/tests/test_epub_exporter.py index b2e572e..4b5f847 100644 --- a/tests/test_epub_exporter.py +++ b/tests/test_epub_exporter.py @@ -177,4 +177,78 @@ def test_epub3_preserves_original_whitespace(tmp_path) -> None: match = re.search(r"
]*>(.*?)
", chapter_doc, re.DOTALL) assert match is not None original_text = html.unescape(match.group(1)) - assert "Second line\n\nThird paragraph." in original_text \ No newline at end of file + assert "Second line\n\nThird paragraph." in original_text + + +def test_epub3_sentence_chunks_render_as_paragraphs(tmp_path) -> None: + extraction = ExtractionResult( + chapters=[ + ExtractedChapter( + title="Chapter 1", + text="First sentence. Second sentence in same paragraph.\n\nNew paragraph starts here.", + ) + ], + metadata={"title": "Sample", "artist": "Author", "language": "en"}, + ) + + chunks = [ + { + "id": "chap0000_p0000_s0000", + "chapter_index": 0, + "chunk_index": 0, + "text": "First sentence.", + "level": "sentence", + "speaker_id": "narrator", + }, + { + "id": "chap0000_p0000_s0001", + "chapter_index": 0, + "chunk_index": 1, + "text": "Second sentence in same paragraph.", + "level": "sentence", + "speaker_id": "narrator", + }, + { + "id": "chap0000_p0001_s0000", + "chapter_index": 0, + "chunk_index": 2, + "text": "New paragraph starts here.", + "level": "sentence", + "speaker_id": "narrator", + }, + ] + + chunk_markers = [ + {"id": chunk["id"], "chapter_index": 0, "chunk_index": chunk["chunk_index"], "start": None, "end": None} + for chunk in chunks + ] + + audio_path = tmp_path / "audio.mp3" + audio_path.write_bytes(b"ID3 audio") + output_path = tmp_path / "output.epub" + + build_epub3_package( + output_path=output_path, + book_id="job-paragraphs", + extraction=extraction, + metadata_tags={"title": "Sample", "artist": "Author", "language": "en"}, + chapter_markers=[], + chunk_markers=chunk_markers, + chunks=chunks, + audio_path=audio_path, + speaker_mode="single", + ) + + with zipfile.ZipFile(output_path) as archive: + chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8") + + assert '
', first_paragraph_start) + first_paragraph = chapter_doc[first_paragraph_start:first_paragraph_end] + assert "First sentence." in first_paragraph + assert "Second sentence in same paragraph." in first_paragraph \ No newline at end of file diff --git a/tests/test_voice_cache.py b/tests/test_voice_cache.py index 5c1e9c1..ea89f3f 100644 --- a/tests/test_voice_cache.py +++ b/tests/test_voice_cache.py @@ -4,7 +4,7 @@ from typing import cast import pytest from abogen.constants import VOICES_INTERNAL -from abogen.voice_cache import _CACHED_VOICES, ensure_voice_assets +from abogen.voice_cache import LocalEntryNotFoundError, _CACHED_VOICES, ensure_voice_assets from abogen.web.conversion_runner import _collect_required_voice_ids from abogen.web.service import Job @@ -19,9 +19,18 @@ def clear_voice_cache(): def test_ensure_voice_assets_downloads_missing(monkeypatch): recorded = [] + cached = set() + def fake_download(**kwargs): - recorded.append(kwargs["filename"]) - return "/tmp/fake" + filename = kwargs["filename"] + if kwargs.get("local_files_only"): + if filename in cached: + return f"/tmp/{filename}" + raise LocalEntryNotFoundError(f"{filename} missing") + + recorded.append(filename) + cached.add(filename) + return f"/tmp/{filename}" monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)