diff --git a/abogen/domain/chapter_titles.py b/abogen/domain/chapter_titles.py index 1d39288..be2dc9e 100644 --- a/abogen/domain/chapter_titles.py +++ b/abogen/domain/chapter_titles.py @@ -170,3 +170,35 @@ def format_spoken_chapter_title(title: str, index: int, apply_prefix: bool) -> s return f"Chapter {number}. {cleaned_suffix}" return f"Chapter {number}" return base + + +def apply_chapter_text_transforms( + text: str, + *, + heading_text: str, + raw_title: str, + strip_heading: bool, + normalize_caps: bool, +) -> Tuple[str, bool, bool]: + """Strip duplicate heading and normalize opening caps. + + Returns ``(text, heading_removed, caps_changed)``. + The caller is responsible for state updates (pending flags, logging, + dict mutation, ``continue``). + """ + heading_removed = False + caps_changed = False + + if strip_heading and heading_text: + text, heading_removed = strip_duplicate_heading_line(text, heading_text) + if not heading_removed and raw_title: + match = _HEADING_NUMBER_PREFIX_RE.match(raw_title) + if match: + number = match.group("number") + if number: + text, heading_removed = strip_duplicate_heading_line(text, number) + + if normalize_caps and text: + text, caps_changed = normalize_chapter_opening_caps(text) + + return text, heading_removed, caps_changed diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index 8370226..2050917 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -48,6 +48,7 @@ from abogen.domain.chapter_titles import ( normalize_caps_word as _normalize_caps_word, normalize_chapter_opening_caps as _normalize_chapter_opening_caps, format_spoken_chapter_title as _format_spoken_chapter_title, + apply_chapter_text_transforms as _apply_chapter_text_transforms, _HEADING_NUMBER_PREFIX_RE, ) from abogen.domain.metadata_helpers import ( @@ -780,39 +781,32 @@ def run_conversion_job(job: Job) -> None: continue mutated_entry = False - if pending_heading_strip and heading_text: - chunk_text, removed_heading = _strip_duplicate_heading_line(chunk_text, heading_text) - if not removed_heading and raw_title: - match = _HEADING_NUMBER_PREFIX_RE.match(raw_title) - if match: - number = match.group("number") - if number: - chunk_text, removed_heading = _strip_duplicate_heading_line(chunk_text, number) - - if removed_heading: - pending_heading_strip = False + chunk_text, heading_removed, caps_changed = _apply_chapter_text_transforms( + chunk_text, + heading_text=heading_text, + raw_title=raw_title, + strip_heading=pending_heading_strip, + normalize_caps=opening_caps_pending, + ) + if heading_removed: + pending_heading_strip = False + chunk_entry = dict(chunk_entry) + chunk_entry["normalized_text"] = chunk_text + mutated_entry = True + if not chunk_text.strip(): + continue + if caps_changed: + if not mutated_entry: chunk_entry = dict(chunk_entry) - chunk_entry["normalized_text"] = chunk_text - mutated_entry = True - if not chunk_text.strip(): - continue - - if opening_caps_pending and chunk_text: - normalized_text, normalized_changed = _normalize_chapter_opening_caps(chunk_text) - if normalized_changed: - if not mutated_entry: - chunk_entry = dict(chunk_entry) - mutated_entry = True - chunk_entry["normalized_text"] = normalized_text - chunk_text = normalized_text - if not opening_caps_logged: - job.add_log( - f"Normalized uppercase chapter opening for chapter {idx}.", - level="debug", - ) - opening_caps_logged = True - if chunk_text.strip(): - opening_caps_pending = False + chunk_entry["normalized_text"] = chunk_text + if not opening_caps_logged: + job.add_log( + f"Normalized uppercase chapter opening for chapter {idx}.", + level="debug", + ) + opening_caps_logged = True + if chunk_text.strip(): + opening_caps_pending = False chunk_voice_spec = _chunk_voice_spec( job, @@ -878,29 +872,24 @@ def run_conversion_job(job: Job) -> None: if body_segments == 0: chapter_body_start = current_time chapter_text = str(chapter.text or "") - if pending_heading_strip and heading_text: - chapter_text, removed_heading = _strip_duplicate_heading_line(chapter_text, heading_text) - if not removed_heading and raw_title: - match = _HEADING_NUMBER_PREFIX_RE.match(raw_title) - if match: - number = match.group("number") - if number: - chapter_text, removed_heading = _strip_duplicate_heading_line(chapter_text, number) - - if removed_heading: - pending_heading_strip = False - if opening_caps_pending and chapter_text: - normalized_body, normalized_changed = _normalize_chapter_opening_caps(chapter_text) - if normalized_changed: - chapter_text = normalized_body - if not opening_caps_logged: - job.add_log( - f"Normalized uppercase chapter opening for chapter {idx}.", - level="debug", - ) - opening_caps_logged = True - if str(chapter_text or "").strip(): - opening_caps_pending = False + chapter_text, heading_removed, caps_changed = _apply_chapter_text_transforms( + chapter_text, + heading_text=heading_text, + raw_title=raw_title, + strip_heading=pending_heading_strip, + normalize_caps=opening_caps_pending, + ) + if heading_removed: + pending_heading_strip = False + if caps_changed: + if not opening_caps_logged: + job.add_log( + f"Normalized uppercase chapter opening for chapter {idx}.", + level="debug", + ) + opening_caps_logged = True + if str(chapter_text or "").strip(): + opening_caps_pending = False emitted = emit_text( chapter_text, voice_choice=voice_choice, diff --git a/tests/test_conversion_chapter_titles.py b/tests/test_conversion_chapter_titles.py index 5fe6a4d..46e935c 100644 --- a/tests/test_conversion_chapter_titles.py +++ b/tests/test_conversion_chapter_titles.py @@ -124,3 +124,117 @@ def test_normalize_chapter_opening_caps_keeps_mixed_case() -> None: normalized, changed = _normalize_chapter_opening_caps("Already Mixed Case") assert normalized == "Already Mixed Case" assert changed is False + + +class TestApplyChapterTextTransforms: + """Tests for the combined heading-strip + opening-caps helper.""" + + def test_both_enabled_heading_matches(self) -> None: + from abogen.domain.chapter_titles import apply_chapter_text_transforms + + text, heading_removed, caps_changed = apply_chapter_text_transforms( + "Chapter 1: The Beginning\nBody text here", + heading_text="Chapter 1: The Beginning", + raw_title="Chapter 1: The Beginning", + strip_heading=True, + normalize_caps=True, + ) + assert heading_removed is True + assert "Body text here" in text + assert "Chapter 1" not in text + + def test_heading_fallback_to_number(self) -> None: + from abogen.domain.chapter_titles import apply_chapter_text_transforms + + text, heading_removed, caps_changed = apply_chapter_text_transforms( + "1. The Beginning\nBody text", + heading_text="Chapter 1: The Beginning", + raw_title="1: The Beginning", + strip_heading=True, + normalize_caps=False, + ) + assert heading_removed is True + assert "Body text" in text + + def test_only_heading_strip(self) -> None: + from abogen.domain.chapter_titles import apply_chapter_text_transforms + + text, heading_removed, caps_changed = apply_chapter_text_transforms( + "Chapter 1: Title\nBody text", + heading_text="Chapter 1: Title", + raw_title="", + strip_heading=True, + normalize_caps=False, + ) + assert heading_removed is True + assert caps_changed is False + + def test_only_opening_caps(self) -> None: + from abogen.domain.chapter_titles import apply_chapter_text_transforms + + text, heading_removed, caps_changed = apply_chapter_text_transforms( + "ALL CAPS START OF CHAPTER", + heading_text="Chapter 1", + raw_title="", + strip_heading=False, + normalize_caps=True, + ) + assert heading_removed is False + assert caps_changed is True + assert text == "All Caps Start Of Chapter" + + def test_both_disabled_no_change(self) -> None: + from abogen.domain.chapter_titles import apply_chapter_text_transforms + + original = "Some text here" + text, heading_removed, caps_changed = apply_chapter_text_transforms( + original, + heading_text="Chapter 1", + raw_title="", + strip_heading=False, + normalize_caps=False, + ) + assert text == original + assert heading_removed is False + assert caps_changed is False + + def test_heading_not_matching(self) -> None: + from abogen.domain.chapter_titles import apply_chapter_text_transforms + + text, heading_removed, caps_changed = apply_chapter_text_transforms( + "Completely different text", + heading_text="Chapter 1: Title", + raw_title="", + strip_heading=True, + normalize_caps=False, + ) + assert heading_removed is False + assert text == "Completely different text" + + def test_empty_text(self) -> None: + from abogen.domain.chapter_titles import apply_chapter_text_transforms + + text, heading_removed, caps_changed = apply_chapter_text_transforms( + "", + heading_text="Chapter 1", + raw_title="", + strip_heading=True, + normalize_caps=True, + ) + assert text == "" + assert heading_removed is False + assert caps_changed is False + + def test_both_enabled_text_only_has_caps(self) -> None: + from abogen.domain.chapter_titles import apply_chapter_text_transforms + + text, heading_removed, caps_changed = apply_chapter_text_transforms( + "NASA MISSION LOG", + heading_text="Chapter 1", + raw_title="", + strip_heading=True, + normalize_caps=True, + ) + assert heading_removed is False + assert caps_changed is True + assert text == "NASA Mission Log"