"""Furigana parsing and generation utilities.
This module provides utilities for working with furigana (ruby text annotations)
in Japanese translations. It parses the inline bracket format used by Claude:
神[かみ]は愛[あい]です
And converts to structured FuriganaAnnotation objects for further processing.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# Pattern to match kanji with inline furigana: 漢字[かんじ]
# Captures: (base_text)[reading]
FURIGANA_PATTERN = re.compile(r"([一-龯々]+)\[([ぁ-んァ-ヶー]+)\]")
@dataclass(frozen=True)
class FuriganaAnnotation:
"""A furigana annotation marking a kanji reading.
Attributes:
start_offset: Character position in the plain text (0-indexed)
end_offset: End position in the plain text (exclusive)
base_text: The kanji characters (e.g., "神")
reading: The hiragana reading (e.g., "かみ")
is_standard_reading: Whether this is a standard dictionary reading
reading_source: Source of the reading ("dictionary", "context", "theological")
"""
start_offset: int
end_offset: int
base_text: str
reading: str
is_standard_reading: bool = True
reading_source: str = "dictionary"
def parse_furigana(text_with_furigana: str) -> tuple[str, list[FuriganaAnnotation]]:
"""Parse inline furigana notation and extract annotations.
Takes text in the format: 神[かみ]は愛[あい]です
Returns the plain text and a list of FuriganaAnnotation objects.
Args:
text_with_furigana: Text containing inline furigana in bracket notation
Returns:
Tuple of (plain_text, list of FuriganaAnnotation)
- plain_text: Text with furigana markers removed (e.g., "神は愛です")
- annotations: List of FuriganaAnnotation with correct offsets into plain_text
Example:
>>> plain, annotations = parse_furigana("神[かみ]は愛[あい]です")
>>> plain
'神は愛です'
>>> annotations[0]
FuriganaAnnotation(start_offset=0, end_offset=1, base_text='神', reading='かみ', ...)
>>> annotations[1]
FuriganaAnnotation(start_offset=2, end_offset=3, base_text='愛', reading='あい', ...)
"""
annotations: list[FuriganaAnnotation] = []
plain_parts: list[str] = []
plain_offset = 0
last_end = 0
for match in FURIGANA_PATTERN.finditer(text_with_furigana):
# Add text before this match
plain_parts.append(text_with_furigana[last_end : match.start()])
plain_offset += match.start() - last_end
# Extract the kanji and reading
base_text = match.group(1)
reading = match.group(2)
# Create annotation with offset in plain text
annotation = FuriganaAnnotation(
start_offset=plain_offset,
end_offset=plain_offset + len(base_text),
base_text=base_text,
reading=reading,
)
annotations.append(annotation)
# Add the base text to plain output
plain_parts.append(base_text)
plain_offset += len(base_text)
last_end = match.end()
# Add remaining text after last match
plain_parts.append(text_with_furigana[last_end:])
plain_text = "".join(plain_parts)
return plain_text, annotations
def format_furigana_inline(plain_text: str, annotations: list[FuriganaAnnotation]) -> str:
"""Convert plain text and annotations back to inline furigana format.
Args:
plain_text: Plain Japanese text without furigana
annotations: List of FuriganaAnnotation objects (must be sorted by start_offset)
Returns:
Text with inline furigana notation: 神[かみ]は愛[あい]です
Example:
>>> annotations = [
... FuriganaAnnotation(0, 1, "神", "かみ"),
... FuriganaAnnotation(2, 3, "愛", "あい"),
... ]
>>> format_furigana_inline("神は愛です", annotations)
'神[かみ]は愛[あい]です'
"""
if not annotations:
return plain_text
# Sort annotations by start offset
sorted_annotations = sorted(annotations, key=lambda a: a.start_offset)
parts: list[str] = []
last_end = 0
for annotation in sorted_annotations:
# Add text before this annotation
parts.append(plain_text[last_end : annotation.start_offset])
# Add the annotated text with furigana
parts.append(f"{annotation.base_text}[{annotation.reading}]")
last_end = annotation.end_offset
# Add remaining text
parts.append(plain_text[last_end:])
return "".join(parts)
def format_furigana_html(plain_text: str, annotations: list[FuriganaAnnotation]) -> str:
"""Convert plain text and annotations to HTML ruby format.
Args:
plain_text: Plain Japanese text without furigana
annotations: List of FuriganaAnnotation objects (must be sorted by start_offset)
Returns:
HTML with ruby annotations: 神は愛です
Example:
>>> annotations = [
... FuriganaAnnotation(0, 1, "神", "かみ"),
... FuriganaAnnotation(2, 3, "愛", "あい"),
... ]
>>> format_furigana_html("神は愛です", annotations)
'神は愛です'
"""
if not annotations:
return plain_text
# Sort annotations by start offset
sorted_annotations = sorted(annotations, key=lambda a: a.start_offset)
parts: list[str] = []
last_end = 0
for annotation in sorted_annotations:
# Add text before this annotation
parts.append(plain_text[last_end : annotation.start_offset])
# Add the ruby element
parts.append(f"{annotation.base_text}")
last_end = annotation.end_offset
# Add remaining text
parts.append(plain_text[last_end:])
return "".join(parts)
def validate_furigana_text(text_with_furigana: str) -> list[str]:
"""Validate furigana notation in text.
Args:
text_with_furigana: Text containing inline furigana in bracket notation
Returns:
List of validation error messages (empty if valid)
Example:
>>> validate_furigana_text("神[かみ]は愛[あい]です")
[]
>>> validate_furigana_text("神[かみは愛[あい]です") # Unmatched bracket
['Unmatched opening bracket at position 1']
"""
errors: list[str] = []
# Check for unmatched brackets. A stack of open positions names the bracket
# that was actually left open; counting brackets after each "[" blamed the
# first one whenever any later "[" existed (漢[かん]字[じ reported 1, not 6).
open_positions: list[int] = []
for i, char in enumerate(text_with_furigana):
if char == "[":
if open_positions:
errors.append(f"Nested bracket at position {i}")
open_positions.append(i)
elif char == "]":
if not open_positions:
errors.append(f"Unmatched closing bracket at position {i}")
else:
open_positions.pop()
if open_positions:
errors.append(f"Unmatched opening bracket at position {open_positions[0]}")
# Check for invalid patterns (non-kanji before brackets, non-kana in brackets)
# Pattern: something other than kanji followed by [
invalid_base = re.compile(r"([^一-龯々])\[[ぁ-んァ-ヶー]+\]")
for match in invalid_base.finditer(text_with_furigana):
errors.append(
f"Non-kanji character '{match.group(1)}' before furigana bracket at position {match.start()}"
)
return errors
def extract_all_readings(text_with_furigana: str) -> dict[str, list[str]]:
"""Extract all kanji-reading pairs from text.
Args:
text_with_furigana: Text containing inline furigana
Returns:
Dictionary mapping kanji to list of readings found
Example:
>>> extract_all_readings("神[かみ]は神[しん]の愛[あい]")
{'神': ['かみ', 'しん'], '愛': ['あい']}
"""
readings: dict[str, list[str]] = {}
for match in FURIGANA_PATTERN.finditer(text_with_furigana):
base_text = match.group(1)
reading = match.group(2)
if base_text not in readings:
readings[base_text] = []
if reading not in readings[base_text]:
readings[base_text].append(reading)
return readings