"""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}{annotation.reading}") 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