mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Integrate roman numeral normalization in chapter titles and enhance related tests
This commit is contained in:
@@ -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, Optional
|
from typing import Callable, Iterable, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
# ---------- Configuration Dataclass ----------
|
# ---------- Configuration Dataclass ----------
|
||||||
|
|
||||||
@@ -116,7 +116,12 @@ ACRONYM_POSSESSIVE_RE = re.compile(r"^[A-Z]{2,}'s$")
|
|||||||
|
|
||||||
INTERNAL_APOSTROPHE_RE = re.compile(r"[A-Za-z]'.+[A-Za-z]") # apostrophe not at edge
|
INTERNAL_APOSTROPHE_RE = re.compile(r"[A-Za-z]'.+[A-Za-z]") # apostrophe not at edge
|
||||||
|
|
||||||
WORD_TOKEN_RE = re.compile(r"[A-Za-z0-9'’]+|[^A-Za-z0-9\s]")
|
# Capture contiguous runs of Unicode letters/digits/apostrophes/hyphens, otherwise fall back to
|
||||||
|
# single-character tokens (punctuation, symbols, etc.).
|
||||||
|
WORD_TOKEN_RE = re.compile(
|
||||||
|
r"[0-9A-Za-z'’\u00C0-\u1FFF\u2C00-\uD7FF\-]+|[^0-9A-Za-z\s]",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
APOSTROPHE_CHARS = "’`´ꞌʼ"
|
APOSTROPHE_CHARS = "’`´ꞌʼ"
|
||||||
|
|
||||||
@@ -161,6 +166,154 @@ def tokenize(text: str) -> List[str]:
|
|||||||
return WORD_TOKEN_RE.findall(text)
|
return WORD_TOKEN_RE.findall(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_spacing(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
for marker in ("\ufeff", "\u200b", "\u200c", "\u200d", "\u2060"):
|
||||||
|
text = text.replace(marker, "")
|
||||||
|
|
||||||
|
# Collapse spaces before closing punctuation.
|
||||||
|
text = re.sub(r"\s+([,.;:!?%])", r"\1", text)
|
||||||
|
text = re.sub(r"\s+([’\"”»›)\]\}])", r"\1", text)
|
||||||
|
|
||||||
|
# Remove spaces directly after opening punctuation/quotes.
|
||||||
|
text = re.sub(r"([«‹“‘\"'(\[\{])\s+", r"\1", text)
|
||||||
|
|
||||||
|
# Ensure spaces exist after sentence punctuation when followed by a word/quote.
|
||||||
|
text = re.sub(r"([,.;:!?%])(?![\s”'\"’»›)])", r"\1 ", text)
|
||||||
|
text = re.sub(r"([”\"’])(?![\s.,;:!?\"”’»›)])", r"\1 ", text)
|
||||||
|
|
||||||
|
# Tighten hyphen/em dash spacing between word characters.
|
||||||
|
text = re.sub(r"(?<=\w)\s*([-–—])\s*(?=\w)", r"\1", text)
|
||||||
|
|
||||||
|
# Normalize multiple spaces.
|
||||||
|
text = re.sub(r"\s{2,}", " ", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
_ROMAN_VALUE_MAP = {
|
||||||
|
"I": 1,
|
||||||
|
"V": 5,
|
||||||
|
"X": 10,
|
||||||
|
"L": 50,
|
||||||
|
"C": 100,
|
||||||
|
"D": 500,
|
||||||
|
"M": 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ROMAN_COMPOSE_ORDER = [
|
||||||
|
(1000, "M"),
|
||||||
|
(900, "CM"),
|
||||||
|
(500, "D"),
|
||||||
|
(400, "CD"),
|
||||||
|
(100, "C"),
|
||||||
|
(90, "XC"),
|
||||||
|
(50, "L"),
|
||||||
|
(40, "XL"),
|
||||||
|
(10, "X"),
|
||||||
|
(9, "IX"),
|
||||||
|
(5, "V"),
|
||||||
|
(4, "IV"),
|
||||||
|
(1, "I"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_ROMAN_PREFIX_RE = re.compile(r"^(?P<roman>[IVXLCDM]+)(?P<sep>[\s\.:,;\-–—]*)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _roman_to_int(token: str) -> Optional[int]:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
total = 0
|
||||||
|
prev = 0
|
||||||
|
token_upper = token.upper()
|
||||||
|
for char in reversed(token_upper):
|
||||||
|
value = _ROMAN_VALUE_MAP.get(char)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value < prev:
|
||||||
|
total -= value
|
||||||
|
else:
|
||||||
|
total += value
|
||||||
|
prev = value
|
||||||
|
if total <= 0:
|
||||||
|
return None
|
||||||
|
if _int_to_roman(total) != token_upper:
|
||||||
|
return None
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _int_to_roman(value: int) -> str:
|
||||||
|
parts: List[str] = []
|
||||||
|
remaining = value
|
||||||
|
for amount, symbol in _ROMAN_COMPOSE_ORDER:
|
||||||
|
while remaining >= amount:
|
||||||
|
parts.append(symbol)
|
||||||
|
remaining -= amount
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_roman_numeral_titles(
|
||||||
|
titles: Sequence[str],
|
||||||
|
*,
|
||||||
|
threshold: float = 0.5,
|
||||||
|
) -> List[str]:
|
||||||
|
if not titles:
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized: List[str] = []
|
||||||
|
matches: List[Tuple[int, str, int, str, str]] = []
|
||||||
|
non_empty = 0
|
||||||
|
|
||||||
|
for index, raw in enumerate(titles):
|
||||||
|
title = "" if raw is None else str(raw)
|
||||||
|
stripped = title.lstrip()
|
||||||
|
leading_ws = title[: len(title) - len(stripped)]
|
||||||
|
if not stripped:
|
||||||
|
normalized.append(title)
|
||||||
|
continue
|
||||||
|
|
||||||
|
non_empty += 1
|
||||||
|
match = _ROMAN_PREFIX_RE.match(stripped)
|
||||||
|
if not match:
|
||||||
|
normalized.append(title)
|
||||||
|
continue
|
||||||
|
|
||||||
|
roman_token = match.group("roman")
|
||||||
|
separator = match.group("sep") or ""
|
||||||
|
rest = stripped[match.end():]
|
||||||
|
|
||||||
|
if not separator and rest and rest[:1].isalnum():
|
||||||
|
normalized.append(title)
|
||||||
|
continue
|
||||||
|
|
||||||
|
numeric_value = _roman_to_int(roman_token)
|
||||||
|
if numeric_value is None:
|
||||||
|
normalized.append(title)
|
||||||
|
continue
|
||||||
|
|
||||||
|
matches.append((index, leading_ws, numeric_value, separator, rest))
|
||||||
|
normalized.append(title)
|
||||||
|
|
||||||
|
if not matches or non_empty == 0:
|
||||||
|
return list(normalized)
|
||||||
|
|
||||||
|
if len(matches) <= non_empty * threshold:
|
||||||
|
return list(normalized)
|
||||||
|
|
||||||
|
output = list(normalized)
|
||||||
|
for idx, leading_ws, value, separator, rest in matches:
|
||||||
|
new_title = f"{leading_ws}{value}"
|
||||||
|
if separator:
|
||||||
|
new_title += separator
|
||||||
|
elif rest and not rest[0].isspace() and rest[0] not in ".-–—:;,":
|
||||||
|
new_title += " "
|
||||||
|
new_title += rest
|
||||||
|
output[idx] = new_title
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
def _match_casing(template: str, replacement: str) -> str:
|
def _match_casing(template: str, replacement: str) -> str:
|
||||||
if template.isupper():
|
if template.isupper():
|
||||||
return replacement.upper()
|
return replacement.upper()
|
||||||
@@ -385,26 +538,8 @@ def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tup
|
|||||||
results.append((tok, category, norm))
|
results.append((tok, category, norm))
|
||||||
normalized_tokens.append(norm)
|
normalized_tokens.append(norm)
|
||||||
|
|
||||||
# Simple rejoin heuristic:
|
filtered = [token for token in normalized_tokens if token]
|
||||||
# If token is purely punctuation, attach without extra space.
|
normalized_text = _cleanup_spacing(" ".join(filtered))
|
||||||
out_parts = []
|
|
||||||
for i, (orig, cat, norm) in enumerate(results):
|
|
||||||
if i == 0:
|
|
||||||
out_parts.append(norm)
|
|
||||||
continue
|
|
||||||
prev = results[i-1][2]
|
|
||||||
if re.match(r"^[.,;:!?)]$", norm):
|
|
||||||
# Attach to previous
|
|
||||||
out_parts[-1] = out_parts[-1] + norm
|
|
||||||
elif re.match(r"^[(]$", norm):
|
|
||||||
out_parts.append(norm)
|
|
||||||
else:
|
|
||||||
# Normal separation
|
|
||||||
if not (re.match(r"^[.,;:!?)]$", prev) or prev.endswith("—")):
|
|
||||||
out_parts.append(" " + norm)
|
|
||||||
else:
|
|
||||||
out_parts.append(norm)
|
|
||||||
normalized_text = "".join(out_parts)
|
|
||||||
return normalized_text, results
|
return normalized_text, results
|
||||||
|
|
||||||
# ---------- Optional phoneme hint post-processing ----------
|
# ---------- Optional phoneme hint post-processing ----------
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from abogen.constants import (
|
|||||||
VOICES_INTERNAL,
|
VOICES_INTERNAL,
|
||||||
)
|
)
|
||||||
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
||||||
|
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
|
||||||
from abogen.utils import (
|
from abogen.utils import (
|
||||||
calculate_text_length,
|
calculate_text_length,
|
||||||
clean_text,
|
clean_text,
|
||||||
@@ -2049,6 +2050,13 @@ def enqueue_job() -> ResponseReturnValue:
|
|||||||
|
|
||||||
cover_path, cover_mime = _persist_cover_image(extraction, stored_path)
|
cover_path, cover_mime = _persist_cover_image(extraction, stored_path)
|
||||||
|
|
||||||
|
if extraction.chapters:
|
||||||
|
original_titles = [chapter.title for chapter in extraction.chapters]
|
||||||
|
normalized_titles = normalize_roman_numeral_titles(original_titles)
|
||||||
|
if normalized_titles != original_titles:
|
||||||
|
for chapter, new_title in zip(extraction.chapters, normalized_titles):
|
||||||
|
chapter.title = new_title
|
||||||
|
|
||||||
metadata_tags = extraction.metadata or {}
|
metadata_tags = extraction.metadata or {}
|
||||||
total_chars = extraction.total_characters or calculate_text_length(extraction.combined_text)
|
total_chars = extraction.total_characters or calculate_text_length(extraction.combined_text)
|
||||||
total_chapter_count = len(extraction.chapters)
|
total_chapter_count = len(extraction.chapters)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
|
||||||
from abogen.web.conversion_runner import _normalize_for_pipeline
|
from abogen.web.conversion_runner import _normalize_for_pipeline
|
||||||
|
|
||||||
|
|
||||||
@@ -27,3 +28,38 @@ def test_terminal_punctuation_respects_closing_quotes():
|
|||||||
normalized = _normalize_for_pipeline('"Chapter 1"')
|
normalized = _normalize_for_pipeline('"Chapter 1"')
|
||||||
compact = normalized.replace(" ", "")
|
compact = normalized.replace(" ", "")
|
||||||
assert compact.endswith('."')
|
assert compact.endswith('."')
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalization_preserves_spacing_around_quotes_and_hyphen():
|
||||||
|
sample = "“Still,” said Château-Renaud, “Dr. d’Avrigny, who attends my mother, declares he is in despair about it."
|
||||||
|
normalized = _normalize_for_pipeline(sample)
|
||||||
|
|
||||||
|
assert normalized.startswith(
|
||||||
|
"“Still,” said Château-Renaud, “Doctor d'Avrigny, who attends my mother, declares he is in despair about it."
|
||||||
|
)
|
||||||
|
assert " " not in normalized
|
||||||
|
assert "Château-Renaud" in normalized
|
||||||
|
assert "Doctor d'Avrigny" in normalized
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_roman_titles_converts_when_majority() -> None:
|
||||||
|
titles = ["I: Opening", "II: Rising Action", "III: Climax"]
|
||||||
|
normalized = normalize_roman_numeral_titles(titles)
|
||||||
|
|
||||||
|
assert normalized == ["1: Opening", "2: Rising Action", "3: Climax"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_roman_titles_skips_when_not_majority() -> None:
|
||||||
|
titles = ["Preface", "I: Opening", "Acknowledgements"]
|
||||||
|
normalized = normalize_roman_numeral_titles(titles)
|
||||||
|
|
||||||
|
assert normalized == titles
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_roman_titles_preserves_separators() -> None:
|
||||||
|
titles = [" IV. The Trial", "V - The Verdict", "VI\nAftermath"]
|
||||||
|
normalized = normalize_roman_numeral_titles(titles)
|
||||||
|
|
||||||
|
assert normalized[0] == " 4. The Trial"
|
||||||
|
assert normalized[1] == "5 - The Verdict"
|
||||||
|
assert normalized[2].startswith("6\nAftermath")
|
||||||
|
|||||||
Reference in New Issue
Block a user