mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Enhance text normalization and chunking logic to preserve original whitespace and handle abbreviations
This commit is contained in:
+66
-27
@@ -5,11 +5,19 @@ from typing import Dict, Iterable, Iterator, List, Literal, Optional
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline
|
||||||
|
|
||||||
ChunkLevel = Literal["paragraph", "sentence"]
|
ChunkLevel = Literal["paragraph", "sentence"]
|
||||||
|
|
||||||
_SENTENCE_SPLIT_REGEX = re.compile(r"(?<!\b[A-Z])[.!?][\s\n]+")
|
_SENTENCE_SPLIT_REGEX = re.compile(r"(?<!\b[A-Z])[.!?][\s\n]+")
|
||||||
_WHITESPACE_REGEX = re.compile(r"\s+")
|
_WHITESPACE_REGEX = re.compile(r"\s+")
|
||||||
_PARAGRAPH_SPLIT_REGEX = re.compile(r"(?:\r?\n){2,}")
|
_PARAGRAPH_SPLIT_REGEX = re.compile(r"(?:\r?\n){2,}")
|
||||||
|
_ABBREVIATION_END_RE = re.compile(
|
||||||
|
r"\b(?:Mr|Mrs|Ms|Dr|Prof|Rev|Sr|Jr|St|Gen|Lt|Col|Sgt|Capt|Adm|Cmdr|vs|etc)\.$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PIPELINE_APOSTROPHE_CONFIG = ApostropheConfig()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -64,6 +72,37 @@ def _normalize_whitespace(value: str) -> str:
|
|||||||
return _WHITESPACE_REGEX.sub(" ", value).strip()
|
return _WHITESPACE_REGEX.sub(" ", value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_chunk_text(value: str) -> str:
|
||||||
|
normalized = normalize_for_pipeline(value, config=_PIPELINE_APOSTROPHE_CONFIG)
|
||||||
|
return _normalize_whitespace(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_sentences(paragraph: str) -> List[str]:
|
||||||
|
sentences = list(_iter_sentences(paragraph))
|
||||||
|
if not sentences:
|
||||||
|
return []
|
||||||
|
|
||||||
|
merged: List[str] = []
|
||||||
|
buffer: List[str] = []
|
||||||
|
|
||||||
|
for sentence in sentences:
|
||||||
|
if buffer:
|
||||||
|
buffer.append(sentence)
|
||||||
|
else:
|
||||||
|
buffer = [sentence]
|
||||||
|
|
||||||
|
if _ABBREVIATION_END_RE.search(sentence.rstrip()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
merged.append(" ".join(buffer))
|
||||||
|
buffer = []
|
||||||
|
|
||||||
|
if buffer:
|
||||||
|
merged.append(" ".join(buffer))
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def chunk_text(
|
def chunk_text(
|
||||||
*,
|
*,
|
||||||
chapter_index: int,
|
chapter_index: int,
|
||||||
@@ -88,19 +127,19 @@ def chunk_text(
|
|||||||
if not normalized:
|
if not normalized:
|
||||||
continue
|
continue
|
||||||
chunk_id = f"{prefix}_p{para_index:04d}"
|
chunk_id = f"{prefix}_p{para_index:04d}"
|
||||||
chunks.append(
|
payload = Chunk(
|
||||||
Chunk(
|
id=chunk_id,
|
||||||
id=chunk_id,
|
chapter_index=chapter_index,
|
||||||
chapter_index=chapter_index,
|
chunk_index=len(chunks),
|
||||||
chunk_index=len(chunks),
|
level=level,
|
||||||
level=level,
|
text=normalized,
|
||||||
text=normalized,
|
speaker_id=speaker_id,
|
||||||
speaker_id=speaker_id,
|
voice=voice,
|
||||||
voice=voice,
|
voice_profile=voice_profile,
|
||||||
voice_profile=voice_profile,
|
voice_formula=voice_formula,
|
||||||
voice_formula=voice_formula,
|
).as_dict()
|
||||||
).as_dict()
|
payload["normalized_text"] = _normalize_chunk_text(paragraph)
|
||||||
)
|
chunks.append(payload)
|
||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
# Sentence level – flatten paragraphs into individual sentences
|
# Sentence level – flatten paragraphs into individual sentences
|
||||||
@@ -109,25 +148,25 @@ def chunk_text(
|
|||||||
normalized_para = _normalize_whitespace(paragraph)
|
normalized_para = _normalize_whitespace(paragraph)
|
||||||
if not normalized_para:
|
if not normalized_para:
|
||||||
continue
|
continue
|
||||||
sentences = list(_iter_sentences(normalized_para)) or [normalized_para]
|
sentences = _split_sentences(normalized_para) or [normalized_para]
|
||||||
for sent_local_index, sentence in enumerate(sentences):
|
for sent_local_index, sentence in enumerate(sentences):
|
||||||
normalized_sentence = _normalize_whitespace(sentence)
|
normalized_sentence = _normalize_whitespace(sentence)
|
||||||
if not normalized_sentence:
|
if not normalized_sentence:
|
||||||
continue
|
continue
|
||||||
chunk_id = f"{prefix}_p{para_index:04d}_s{sent_local_index:04d}"
|
chunk_id = f"{prefix}_p{para_index:04d}_s{sent_local_index:04d}"
|
||||||
chunks.append(
|
payload = Chunk(
|
||||||
Chunk(
|
id=chunk_id,
|
||||||
id=chunk_id,
|
chapter_index=chapter_index,
|
||||||
chapter_index=chapter_index,
|
chunk_index=sentence_index,
|
||||||
chunk_index=sentence_index,
|
level=level,
|
||||||
level=level,
|
text=normalized_sentence,
|
||||||
text=normalized_sentence,
|
speaker_id=speaker_id,
|
||||||
speaker_id=speaker_id,
|
voice=voice,
|
||||||
voice=voice,
|
voice_profile=voice_profile,
|
||||||
voice_profile=voice_profile,
|
voice_formula=voice_formula,
|
||||||
voice_formula=voice_formula,
|
).as_dict()
|
||||||
).as_dict()
|
payload["normalized_text"] = _normalize_chunk_text(sentence)
|
||||||
)
|
chunks.append(payload)
|
||||||
sentence_index += 1
|
sentence_index += 1
|
||||||
|
|
||||||
return chunks
|
return chunks
|
||||||
|
|||||||
+61
-27
@@ -1,13 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from abogen.text_extractor import ExtractedChapter, ExtractionResult
|
from abogen.text_extractor import ExtractedChapter, ExtractionResult
|
||||||
@@ -259,6 +260,13 @@ class EPUB3PackageBuilder:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
chapter_text = ""
|
||||||
|
if 0 <= chapter_index < len(self.extraction.chapters):
|
||||||
|
chapter_entry = self.extraction.chapters[chapter_index]
|
||||||
|
chapter_text = getattr(chapter_entry, "text", "") or ""
|
||||||
|
|
||||||
|
_restore_original_chunk_text(chapter_text, overlays)
|
||||||
|
|
||||||
return overlays
|
return overlays
|
||||||
|
|
||||||
def _render_chapter_xhtml(self, chapter: ChapterDocument) -> str:
|
def _render_chapter_xhtml(self, chapter: ChapterDocument) -> str:
|
||||||
@@ -617,35 +625,18 @@ def _render_chunk_html(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 ""
|
||||||
paragraphs = _split_paragraphs(chunk.text)
|
raw_text = chunk.text or ""
|
||||||
if not paragraphs:
|
escaped_text = html.escape(raw_text)
|
||||||
paragraphs = [" "]
|
if not escaped_text:
|
||||||
return " <div class=\"chunk\" id=\"{id}\"{speaker}{voice}>\n{body}\n </div>".format(
|
escaped_text = " "
|
||||||
id=escaped_id,
|
body = escaped_text.replace("\n", "\n ")
|
||||||
speaker=speaker_attr,
|
return (
|
||||||
voice=voice_attr,
|
f" <div class=\"chunk\" id=\"{escaped_id}\"{speaker_attr}{voice_attr}>\n"
|
||||||
body="\n".join(f" <p>{para}</p>" for para in paragraphs),
|
f" {body}\n"
|
||||||
|
" </div>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _split_paragraphs(text: str) -> List[str]:
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
segments = [segment.strip() for segment in text.replace("\r", "").split("\n\n")]
|
|
||||||
paragraphs: List[str] = []
|
|
||||||
for segment in segments:
|
|
||||||
if not segment:
|
|
||||||
continue
|
|
||||||
lines = [html.escape(line.strip()) for line in segment.split("\n") if line.strip()]
|
|
||||||
if not lines:
|
|
||||||
continue
|
|
||||||
if len(lines) == 1:
|
|
||||||
paragraphs.append(lines[0])
|
|
||||||
else:
|
|
||||||
paragraphs.append("<br />".join(lines))
|
|
||||||
return paragraphs
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -672,6 +663,49 @@ def _safe_float(value: Any) -> Optional[float]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_original_chunk_text(chapter_text: str, overlays: List[ChunkOverlay]) -> None:
|
||||||
|
if not chapter_text or not overlays:
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = 0
|
||||||
|
for chunk in overlays:
|
||||||
|
candidate = chunk.text or ""
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
match = _search_original_span(chapter_text, candidate, cursor)
|
||||||
|
if match is None and cursor:
|
||||||
|
match = _search_original_span(chapter_text, candidate, 0)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
start, end = match
|
||||||
|
chunk.text = chapter_text[start:end]
|
||||||
|
cursor = end
|
||||||
|
|
||||||
|
|
||||||
|
def _search_original_span(source: str, normalized: str, start: int) -> Optional[Tuple[int, int]]:
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
pattern = _build_chunk_pattern(normalized)
|
||||||
|
match = pattern.search(source, start)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.start(1), match.end(1)
|
||||||
|
|
||||||
|
|
||||||
|
_CHUNK_REGEX_CACHE: Dict[str, Pattern[str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chunk_pattern(text: str) -> Pattern[str]:
|
||||||
|
cached = _CHUNK_REGEX_CACHE.get(text)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
escaped = re.escape(text)
|
||||||
|
escaped = escaped.replace(r"\ ", r"\s+")
|
||||||
|
pattern = re.compile(r"(\s*" + escaped + r"\s*)", re.DOTALL)
|
||||||
|
_CHUNK_REGEX_CACHE[text] = pattern
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
|
||||||
def _render_metadata_xml(
|
def _render_metadata_xml(
|
||||||
title: str,
|
title: str,
|
||||||
authors: Sequence[str],
|
authors: Sequence[str],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Tuple, Iterable, Callable
|
from typing import List, Tuple, Iterable, Callable, Optional
|
||||||
|
|
||||||
# ---------- Configuration Dataclass ----------
|
# ---------- Configuration Dataclass ----------
|
||||||
|
|
||||||
@@ -416,6 +416,26 @@ def apply_phoneme_hints(text: str, iz_marker="‹IZ›") -> str:
|
|||||||
"""
|
"""
|
||||||
return text.replace(iz_marker, " iz")
|
return text.replace(iz_marker, " iz")
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_APOSTROPHE_CONFIG = ApostropheConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_for_pipeline(text: str, *, config: Optional[ApostropheConfig] = None) -> str:
|
||||||
|
"""Normalize text for the synthesis pipeline.
|
||||||
|
|
||||||
|
This expands contractions, normalizes apostrophes, and adds phoneme hints
|
||||||
|
using the provided configuration so downstream chunking and synthesis share
|
||||||
|
the same representation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cfg = config or DEFAULT_APOSTROPHE_CONFIG
|
||||||
|
normalized, _details = normalize_apostrophes(text, cfg)
|
||||||
|
normalized = expand_titles_and_suffixes(normalized)
|
||||||
|
normalized = ensure_terminal_punctuation(normalized)
|
||||||
|
if cfg.add_phoneme_hints:
|
||||||
|
normalized = apply_phoneme_hints(normalized, iz_marker=cfg.sibilant_iz_marker)
|
||||||
|
return normalized
|
||||||
|
|
||||||
# ---------- Example Usage ----------
|
# ---------- Example Usage ----------
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ def analyze_speakers(
|
|||||||
|
|
||||||
for chunk in ordered_chunks:
|
for chunk in ordered_chunks:
|
||||||
chunk_id = str(chunk.get("id") or "")
|
chunk_id = str(chunk.get("id") or "")
|
||||||
text = str(chunk.get("text") or "")
|
text = str(chunk.get("normalized_text") or chunk.get("text") or "")
|
||||||
speaker_id, confidence, quote = _infer_chunk_speaker(text, last_explicit)
|
speaker_id, confidence, quote = _infer_chunk_speaker(text, last_explicit)
|
||||||
if speaker_id is None:
|
if speaker_id is None:
|
||||||
speaker_id = last_explicit or narrator_id
|
speaker_id = last_explicit or narrator_id
|
||||||
|
|||||||
@@ -20,13 +20,7 @@ import static_ffmpeg
|
|||||||
|
|
||||||
from abogen.constants import VOICES_INTERNAL
|
from abogen.constants import VOICES_INTERNAL
|
||||||
from abogen.epub3.exporter import build_epub3_package
|
from abogen.epub3.exporter import build_epub3_package
|
||||||
from abogen.kokoro_text_normalization import (
|
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline
|
||||||
ApostropheConfig,
|
|
||||||
apply_phoneme_hints,
|
|
||||||
expand_titles_and_suffixes,
|
|
||||||
ensure_terminal_punctuation,
|
|
||||||
normalize_apostrophes,
|
|
||||||
)
|
|
||||||
from abogen.text_extractor import ExtractedChapter, extract_from_path
|
from abogen.text_extractor import ExtractedChapter, extract_from_path
|
||||||
from abogen.utils import (
|
from abogen.utils import (
|
||||||
calculate_text_length,
|
calculate_text_length,
|
||||||
@@ -402,12 +396,7 @@ _APOSTROPHE_CONFIG = ApostropheConfig()
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_for_pipeline(text: str) -> str:
|
def _normalize_for_pipeline(text: str) -> str:
|
||||||
normalized, _details = normalize_apostrophes(text, _APOSTROPHE_CONFIG)
|
return normalize_for_pipeline(text, config=_APOSTROPHE_CONFIG)
|
||||||
normalized = expand_titles_and_suffixes(normalized)
|
|
||||||
normalized = ensure_terminal_punctuation(normalized)
|
|
||||||
if _APOSTROPHE_CONFIG.add_phoneme_hints:
|
|
||||||
return apply_phoneme_hints(normalized, iz_marker=_APOSTROPHE_CONFIG.sibilant_iz_marker)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _chapter_voice_spec(job: Job, override: Optional[Dict[str, Any]]) -> str:
|
def _chapter_voice_spec(job: Job, override: Optional[Dict[str, Any]]) -> str:
|
||||||
@@ -988,7 +977,11 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
level="debug",
|
level="debug",
|
||||||
)
|
)
|
||||||
for chunk_entry in chunks_for_chapter:
|
for chunk_entry in chunks_for_chapter:
|
||||||
chunk_text = str(chunk_entry.get("text") or "").strip()
|
chunk_text = str(
|
||||||
|
chunk_entry.get("normalized_text")
|
||||||
|
or chunk_entry.get("text")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
if not chunk_text:
|
if not chunk_text:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -1084,6 +1084,10 @@ class ConversionService:
|
|||||||
else:
|
else:
|
||||||
chunk["text"] = ""
|
chunk["text"] = ""
|
||||||
|
|
||||||
|
normalized_value = entry.get("normalized_text")
|
||||||
|
if normalized_value is not None:
|
||||||
|
chunk["normalized_text"] = str(normalized_value)
|
||||||
|
|
||||||
speaker_value = entry.get("speaker_id", entry.get("speaker"))
|
speaker_value = entry.get("speaker_id", entry.get("speaker"))
|
||||||
chunk["speaker_id"] = str(speaker_value) if speaker_value else "narrator"
|
chunk["speaker_id"] = str(speaker_value) if speaker_value else "narrator"
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,12 @@
|
|||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#reader .chunk {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
#reader p,
|
#reader p,
|
||||||
#reader li,
|
#reader li,
|
||||||
#reader blockquote {
|
#reader blockquote {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from abogen.chunking import chunk_text
|
||||||
from abogen.web.conversion_runner import _chunk_voice_spec, _group_chunks_by_chapter
|
from abogen.web.conversion_runner import _chunk_voice_spec, _group_chunks_by_chapter
|
||||||
|
|
||||||
|
|
||||||
@@ -37,3 +38,22 @@ def test_chunk_voice_spec_uses_fallback_when_no_overrides() -> None:
|
|||||||
chunk = {"speaker_id": "unknown"}
|
chunk = {"speaker_id": "unknown"}
|
||||||
|
|
||||||
assert _chunk_voice_spec(job, chunk, "fallback") == "fallback"
|
assert _chunk_voice_spec(job, chunk, "fallback") == "fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunk_text_merges_title_abbreviations() -> None:
|
||||||
|
text = "Dr. Watson met Mr. Holmes at 5 p.m."
|
||||||
|
|
||||||
|
chunks = chunk_text(
|
||||||
|
chapter_index=0,
|
||||||
|
chapter_title="Chapter 1",
|
||||||
|
text=text,
|
||||||
|
level="sentence",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(chunks) == 1
|
||||||
|
chunk = chunks[0]
|
||||||
|
text_value = str(chunk["text"])
|
||||||
|
normalized_value = str(chunk.get("normalized_text") or "")
|
||||||
|
assert normalized_value
|
||||||
|
assert text_value.startswith("Dr.")
|
||||||
|
assert "Doctor" in normalized_value
|
||||||
|
|||||||
@@ -104,4 +104,68 @@ def test_build_epub3_package_handles_missing_markers(tmp_path) -> None:
|
|||||||
nav_doc = archive.read("OEBPS/nav.xhtml").decode("utf-8")
|
nav_doc = archive.read("OEBPS/nav.xhtml").decode("utf-8")
|
||||||
assert "Chapter 1" in nav_doc
|
assert "Chapter 1" in nav_doc
|
||||||
chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8")
|
chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8")
|
||||||
assert "Hello world." in chapter_doc
|
assert "Hello world." in chapter_doc
|
||||||
|
|
||||||
|
|
||||||
|
def test_epub3_preserves_original_whitespace(tmp_path) -> None:
|
||||||
|
extraction = ExtractionResult(
|
||||||
|
chapters=[
|
||||||
|
ExtractedChapter(
|
||||||
|
title="Intro",
|
||||||
|
text="Line one with double spaces.\nSecond line\n\nThird paragraph.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
metadata={"title": "Sample", "artist": "Author", "language": "en"},
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = [
|
||||||
|
{
|
||||||
|
"id": "chap0000_p0000",
|
||||||
|
"chapter_index": 0,
|
||||||
|
"chunk_index": 0,
|
||||||
|
"text": "Line one with double spaces.",
|
||||||
|
"speaker_id": "narrator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chap0000_p0001",
|
||||||
|
"chapter_index": 0,
|
||||||
|
"chunk_index": 1,
|
||||||
|
"text": "Second line",
|
||||||
|
"speaker_id": "narrator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chap0000_p0002",
|
||||||
|
"chapter_index": 0,
|
||||||
|
"chunk_index": 2,
|
||||||
|
"text": "Third paragraph.",
|
||||||
|
"speaker_id": "narrator",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
chunk_markers = [
|
||||||
|
{"id": chunk["id"], "chapter_index": 0, "chunk_index": chunk["chunk_index"], "start": None, "end": None}
|
||||||
|
for chunk in chunks
|
||||||
|
]
|
||||||
|
|
||||||
|
metadata_tags = {"title": "Sample", "artist": "Author", "language": "en"}
|
||||||
|
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-whitespace",
|
||||||
|
extraction=extraction,
|
||||||
|
metadata_tags=metadata_tags,
|
||||||
|
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 "Line one with double spaces." in chapter_doc
|
||||||
|
normalized = chapter_doc.replace(" ", "")
|
||||||
|
assert "Second line\n\nThird paragraph." in normalized
|
||||||
Reference in New Issue
Block a user