diff --git a/abogen/chunking.py b/abogen/chunking.py
index 0b28280..40f500f 100644
--- a/abogen/chunking.py
+++ b/abogen/chunking.py
@@ -5,11 +5,19 @@ from typing import Dict, Iterable, Iterator, List, Literal, Optional
import re
+from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline
+
ChunkLevel = Literal["paragraph", "sentence"]
_SENTENCE_SPLIT_REGEX = re.compile(r"(? str:
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(
*,
chapter_index: int,
@@ -88,19 +127,19 @@ def chunk_text(
if not normalized:
continue
chunk_id = f"{prefix}_p{para_index:04d}"
- chunks.append(
- Chunk(
- id=chunk_id,
- chapter_index=chapter_index,
- chunk_index=len(chunks),
- level=level,
- text=normalized,
- speaker_id=speaker_id,
- voice=voice,
- voice_profile=voice_profile,
- voice_formula=voice_formula,
- ).as_dict()
- )
+ payload = Chunk(
+ id=chunk_id,
+ chapter_index=chapter_index,
+ chunk_index=len(chunks),
+ level=level,
+ text=normalized,
+ speaker_id=speaker_id,
+ voice=voice,
+ voice_profile=voice_profile,
+ voice_formula=voice_formula,
+ ).as_dict()
+ payload["normalized_text"] = _normalize_chunk_text(paragraph)
+ chunks.append(payload)
return chunks
# Sentence level – flatten paragraphs into individual sentences
@@ -109,25 +148,25 @@ def chunk_text(
normalized_para = _normalize_whitespace(paragraph)
if not normalized_para:
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):
normalized_sentence = _normalize_whitespace(sentence)
if not normalized_sentence:
continue
chunk_id = f"{prefix}_p{para_index:04d}_s{sent_local_index:04d}"
- chunks.append(
- Chunk(
- id=chunk_id,
- chapter_index=chapter_index,
- chunk_index=sentence_index,
- level=level,
- text=normalized_sentence,
- speaker_id=speaker_id,
- voice=voice,
- voice_profile=voice_profile,
- voice_formula=voice_formula,
- ).as_dict()
- )
+ payload = Chunk(
+ id=chunk_id,
+ chapter_index=chapter_index,
+ chunk_index=sentence_index,
+ level=level,
+ text=normalized_sentence,
+ speaker_id=speaker_id,
+ voice=voice,
+ voice_profile=voice_profile,
+ voice_formula=voice_formula,
+ ).as_dict()
+ payload["normalized_text"] = _normalize_chunk_text(sentence)
+ chunks.append(payload)
sentence_index += 1
return chunks
diff --git a/abogen/epub3/exporter.py b/abogen/epub3/exporter.py
index 5981ca3..d9f371d 100644
--- a/abogen/epub3/exporter.py
+++ b/abogen/epub3/exporter.py
@@ -1,13 +1,14 @@
from __future__ import annotations
import html
+import re
import shutil
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
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
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
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)
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 ""
- paragraphs = _split_paragraphs(chunk.text)
- if not paragraphs:
- paragraphs = [" "]
- return "
\n{body}\n
".format(
- id=escaped_id,
- speaker=speaker_attr,
- voice=voice_attr,
- body="\n".join(f" {para}
" for para in paragraphs),
+ raw_text = chunk.text or ""
+ escaped_text = html.escape(raw_text)
+ if not escaped_text:
+ escaped_text = " "
+ body = escaped_text.replace("\n", "\n ")
+ return (
+ f" \n"
+ f" {body}\n"
+ "
"
)
-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("
".join(lines))
- return paragraphs
-
-
def _format_smil_time(value: Optional[float]) -> str:
if value is None or value < 0:
value = 0.0
@@ -672,6 +663,49 @@ def _safe_float(value: Any) -> Optional[float]:
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(
title: str,
authors: Sequence[str],
diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py
index 6067a8b..4f9cea3 100644
--- a/abogen/kokoro_text_normalization.py
+++ b/abogen/kokoro_text_normalization.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass
-from typing import List, Tuple, Iterable, Callable
+from typing import List, Tuple, Iterable, Callable, Optional
# ---------- Configuration Dataclass ----------
@@ -416,6 +416,26 @@ def apply_phoneme_hints(text: str, iz_marker="‹IZ›") -> str:
"""
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 ----------
if __name__ == "__main__":
diff --git a/abogen/speaker_analysis.py b/abogen/speaker_analysis.py
index b44e3b2..287f3e7 100644
--- a/abogen/speaker_analysis.py
+++ b/abogen/speaker_analysis.py
@@ -175,7 +175,7 @@ def analyze_speakers(
for chunk in ordered_chunks:
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)
if speaker_id is None:
speaker_id = last_explicit or narrator_id
diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py
index e7cf4d7..85a9f2b 100644
--- a/abogen/web/conversion_runner.py
+++ b/abogen/web/conversion_runner.py
@@ -20,13 +20,7 @@ import static_ffmpeg
from abogen.constants import VOICES_INTERNAL
from abogen.epub3.exporter import build_epub3_package
-from abogen.kokoro_text_normalization import (
- ApostropheConfig,
- apply_phoneme_hints,
- expand_titles_and_suffixes,
- ensure_terminal_punctuation,
- normalize_apostrophes,
-)
+from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline
from abogen.text_extractor import ExtractedChapter, extract_from_path
from abogen.utils import (
calculate_text_length,
@@ -402,12 +396,7 @@ _APOSTROPHE_CONFIG = ApostropheConfig()
def _normalize_for_pipeline(text: str) -> str:
- normalized, _details = normalize_apostrophes(text, _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
+ return normalize_for_pipeline(text, config=_APOSTROPHE_CONFIG)
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",
)
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:
continue
diff --git a/abogen/web/service.py b/abogen/web/service.py
index 6366eec..f21e45b 100644
--- a/abogen/web/service.py
+++ b/abogen/web/service.py
@@ -1084,6 +1084,10 @@ class ConversionService:
else:
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"))
chunk["speaker_id"] = str(speaker_value) if speaker_value else "narrator"
diff --git a/abogen/web/templates/reader_embed.html b/abogen/web/templates/reader_embed.html
index 13d6383..1cc21a4 100644
--- a/abogen/web/templates/reader_embed.html
+++ b/abogen/web/templates/reader_embed.html
@@ -125,6 +125,12 @@
outline-offset: 4px;
}
+ #reader .chunk {
+ margin-bottom: 1.75rem;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
+
#reader p,
#reader li,
#reader blockquote {
diff --git a/tests/test_chunk_helpers.py b/tests/test_chunk_helpers.py
index 70755cc..096bb7c 100644
--- a/tests/test_chunk_helpers.py
+++ b/tests/test_chunk_helpers.py
@@ -2,6 +2,7 @@ from __future__ import annotations
from types import SimpleNamespace
+from abogen.chunking import chunk_text
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"}
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
diff --git a/tests/test_epub_exporter.py b/tests/test_epub_exporter.py
index ad9bfc2..3df703a 100644
--- a/tests/test_epub_exporter.py
+++ b/tests/test_epub_exporter.py
@@ -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")
assert "Chapter 1" in nav_doc
chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8")
- assert "Hello world." in chapter_doc
\ No newline at end of file
+ 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
\ No newline at end of file