mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Enhance EPUB export by grouping chunks for rendering and adding group ID support
This commit is contained in:
+61
-18
@@ -24,6 +24,7 @@ class ChunkOverlay:
|
|||||||
speaker_id: str
|
speaker_id: str
|
||||||
voice: Optional[str]
|
voice: Optional[str]
|
||||||
level: Optional[str] = None
|
level: Optional[str] = None
|
||||||
|
group_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -256,6 +257,10 @@ class EPUB3PackageBuilder:
|
|||||||
normalized_id = f"{normalized_id}_dup"
|
normalized_id = f"{normalized_id}_dup"
|
||||||
used_ids.add(normalized_id)
|
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(
|
overlays.append(
|
||||||
ChunkOverlay(
|
ChunkOverlay(
|
||||||
id=normalized_id,
|
id=normalized_id,
|
||||||
@@ -266,6 +271,7 @@ class EPUB3PackageBuilder:
|
|||||||
speaker_id=speaker_id,
|
speaker_id=speaker_id,
|
||||||
voice=str(voice) if voice else None,
|
voice=str(voice) if voice else None,
|
||||||
level=str(level) if level 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:
|
def _render_chapter_xhtml(self, chapter: ChapterDocument) -> str:
|
||||||
language = html.escape(self._language or "en")
|
language = html.escape(self._language or "en")
|
||||||
title = html.escape(chapter.title)
|
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:
|
if not chunk_html:
|
||||||
chunk_html = "<p></p>"
|
chunk_html = "<p></p>"
|
||||||
original_block = ""
|
original_block = ""
|
||||||
@@ -647,7 +656,40 @@ def _normalize_chunk_id(chunk_id: Optional[Any]) -> Optional[str]:
|
|||||||
return safe[:120]
|
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)
|
escaped_id = html.escape(chunk.id)
|
||||||
speaker_attr = f" data-speaker=\"{html.escape(chunk.speaker_id)}\"" if chunk.speaker_id else ""
|
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 ""
|
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:
|
if not escaped_text:
|
||||||
escaped_text = " "
|
escaped_text = " "
|
||||||
return (
|
return (
|
||||||
f" <div class=\"chunk\" id=\"{escaped_id}\"{speaker_attr}{voice_attr}{level_attr}>"
|
f"<span class=\"chunk\" id=\"{escaped_id}\"{speaker_attr}{voice_attr}{level_attr}>"
|
||||||
f"{escaped_text}"
|
f"{escaped_text}"
|
||||||
"</div>"
|
"</span>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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" <p class=\"chunk-group\"{group_attr}>{inline_html}</p>"
|
||||||
|
|
||||||
|
|
||||||
def _format_smil_time(value: Optional[float]) -> str:
|
def _format_smil_time(value: Optional[float]) -> str:
|
||||||
if value is None or value < 0:
|
if value is None or value < 0:
|
||||||
value = 0.0
|
value = 0.0
|
||||||
@@ -840,20 +892,11 @@ h1 {
|
|||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.chunk {
|
.chunk-group {
|
||||||
margin: 0 0 1em 0;
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-group .chunk {
|
||||||
white-space: pre-wrap;
|
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;
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
|
|||||||
+17
-6
@@ -10,6 +10,10 @@ except Exception: # pragma: no cover - import fallback
|
|||||||
hf_hub_download = None # type: ignore[assignment]
|
hf_hub_download = None # type: ignore[assignment]
|
||||||
LocalEntryNotFoundError = 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
|
from abogen.constants import VOICES_INTERNAL
|
||||||
|
|
||||||
_CACHE_LOCK = threading.Lock()
|
_CACHE_LOCK = threading.Lock()
|
||||||
@@ -116,11 +120,18 @@ def _ensure_single_voice_asset(
|
|||||||
raise RuntimeError("huggingface_hub is required to cache voices")
|
raise RuntimeError("huggingface_hub is required to cache voices")
|
||||||
|
|
||||||
filename = f"voices/{voice_id}.pt"
|
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(
|
try:
|
||||||
repo_id=repo_id,
|
hf_hub_download(local_files_only=True, **common_kwargs)
|
||||||
filename=filename,
|
return False
|
||||||
cache_dir=cache_dir,
|
except LocalEntryNotFoundError:
|
||||||
resume_download=True,
|
pass
|
||||||
)
|
|
||||||
|
hf_hub_download(resume_download=True, **common_kwargs)
|
||||||
return True
|
return True
|
||||||
@@ -177,4 +177,78 @@ def test_epub3_preserves_original_whitespace(tmp_path) -> None:
|
|||||||
match = re.search(r"<pre class=\"chapter-original\"[^>]*>(.*?)</pre>", chapter_doc, re.DOTALL)
|
match = re.search(r"<pre class=\"chapter-original\"[^>]*>(.*?)</pre>", chapter_doc, re.DOTALL)
|
||||||
assert match is not None
|
assert match is not None
|
||||||
original_text = html.unescape(match.group(1))
|
original_text = html.unescape(match.group(1))
|
||||||
assert "Second line\n\nThird paragraph." in original_text
|
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 '<div class="chunk"' not in chapter_doc
|
||||||
|
assert chapter_doc.count('<p class="chunk-group"') == 2
|
||||||
|
assert 'First sentence.' in chapter_doc
|
||||||
|
assert 'Second sentence in same paragraph.' in chapter_doc
|
||||||
|
|
||||||
|
first_paragraph_start = chapter_doc.find('<p class="chunk-group"')
|
||||||
|
first_paragraph_end = chapter_doc.find('</p>', 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
|
||||||
@@ -4,7 +4,7 @@ from typing import cast
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from abogen.constants import VOICES_INTERNAL
|
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.conversion_runner import _collect_required_voice_ids
|
||||||
from abogen.web.service import Job
|
from abogen.web.service import Job
|
||||||
|
|
||||||
@@ -19,9 +19,18 @@ def clear_voice_cache():
|
|||||||
def test_ensure_voice_assets_downloads_missing(monkeypatch):
|
def test_ensure_voice_assets_downloads_missing(monkeypatch):
|
||||||
recorded = []
|
recorded = []
|
||||||
|
|
||||||
|
cached = set()
|
||||||
|
|
||||||
def fake_download(**kwargs):
|
def fake_download(**kwargs):
|
||||||
recorded.append(kwargs["filename"])
|
filename = kwargs["filename"]
|
||||||
return "/tmp/fake"
|
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)
|
monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user