Pre-publication review draft
日常訳NICHIJOYAKU日本語 ↗

OPEN SOURCE

software/src/utils/furigana.py

Read on this site and download the files you need.

Nichijoyaku was created using AI. A complete review by human biblical-language and biblical-studies specialists has not been established. Read alongside established translations and original-language resources.

← Back to the open-source guide

software/src/utils/furigana.py254 linesDownload this file ↓

This is the same source included in the download. Each line number is a link you can save or share.

File verification detailsSHA-256 9e73b7a9d53888347685b73cd70575bed12c7392468a1f5d0f5397f39644c709
1"""Furigana parsing and generation utilities.23This module provides utilities for working with furigana (ruby text annotations)4in Japanese translations. It parses the inline bracket format used by Claude:5    神[かみ]は愛[あい]です67And converts to structured FuriganaAnnotation objects for further processing.8"""910from __future__ import annotations1112import re13from dataclasses import dataclass1415# Pattern to match kanji with inline furigana: 漢字[かんじ]16# Captures: (base_text)[reading]17FURIGANA_PATTERN = re.compile(r"([一-龯々]+)\[([ぁ-んァ-ヶー]+)\]")181920@dataclass(frozen=True)21class FuriganaAnnotation:22    """A furigana annotation marking a kanji reading.2324    Attributes:25        start_offset: Character position in the plain text (0-indexed)26        end_offset: End position in the plain text (exclusive)27        base_text: The kanji characters (e.g., "神")28        reading: The hiragana reading (e.g., "かみ")29        is_standard_reading: Whether this is a standard dictionary reading30        reading_source: Source of the reading ("dictionary", "context", "theological")31    """3233    start_offset: int34    end_offset: int35    base_text: str36    reading: str37    is_standard_reading: bool = True38    reading_source: str = "dictionary"394041def parse_furigana(text_with_furigana: str) -> tuple[str, list[FuriganaAnnotation]]:42    """Parse inline furigana notation and extract annotations.4344    Takes text in the format: 神[かみ]は愛[あい]です45    Returns the plain text and a list of FuriganaAnnotation objects.4647    Args:48        text_with_furigana: Text containing inline furigana in bracket notation4950    Returns:51        Tuple of (plain_text, list of FuriganaAnnotation)52        - plain_text: Text with furigana markers removed (e.g., "神は愛です")53        - annotations: List of FuriganaAnnotation with correct offsets into plain_text5455    Example:56        >>> plain, annotations = parse_furigana("神[かみ]は愛[あい]です")57        >>> plain58        '神は愛です'59        >>> annotations[0]60        FuriganaAnnotation(start_offset=0, end_offset=1, base_text='神', reading='かみ', ...)61        >>> annotations[1]62        FuriganaAnnotation(start_offset=2, end_offset=3, base_text='愛', reading='あい', ...)63    """64    annotations: list[FuriganaAnnotation] = []65    plain_parts: list[str] = []66    plain_offset = 067    last_end = 06869    for match in FURIGANA_PATTERN.finditer(text_with_furigana):70        # Add text before this match71        plain_parts.append(text_with_furigana[last_end : match.start()])72        plain_offset += match.start() - last_end7374        # Extract the kanji and reading75        base_text = match.group(1)76        reading = match.group(2)7778        # Create annotation with offset in plain text79        annotation = FuriganaAnnotation(80            start_offset=plain_offset,81            end_offset=plain_offset + len(base_text),82            base_text=base_text,83            reading=reading,84        )85        annotations.append(annotation)8687        # Add the base text to plain output88        plain_parts.append(base_text)89        plain_offset += len(base_text)90        last_end = match.end()9192    # Add remaining text after last match93    plain_parts.append(text_with_furigana[last_end:])94    plain_text = "".join(plain_parts)9596    return plain_text, annotations979899def format_furigana_inline(plain_text: str, annotations: list[FuriganaAnnotation]) -> str:100    """Convert plain text and annotations back to inline furigana format.101102    Args:103        plain_text: Plain Japanese text without furigana104        annotations: List of FuriganaAnnotation objects (must be sorted by start_offset)105106    Returns:107        Text with inline furigana notation: 神[かみ]は愛[あい]です108109    Example:110        >>> annotations = [111        ...     FuriganaAnnotation(0, 1, "神", "かみ"),112        ...     FuriganaAnnotation(2, 3, "愛", "あい"),113        ... ]114        >>> format_furigana_inline("神は愛です", annotations)115        '神[かみ]は愛[あい]です'116    """117    if not annotations:118        return plain_text119120    # Sort annotations by start offset121    sorted_annotations = sorted(annotations, key=lambda a: a.start_offset)122123    parts: list[str] = []124    last_end = 0125126    for annotation in sorted_annotations:127        # Add text before this annotation128        parts.append(plain_text[last_end : annotation.start_offset])129130        # Add the annotated text with furigana131        parts.append(f"{annotation.base_text}[{annotation.reading}]")132133        last_end = annotation.end_offset134135    # Add remaining text136    parts.append(plain_text[last_end:])137138    return "".join(parts)139140141def format_furigana_html(plain_text: str, annotations: list[FuriganaAnnotation]) -> str:142    """Convert plain text and annotations to HTML ruby format.143144    Args:145        plain_text: Plain Japanese text without furigana146        annotations: List of FuriganaAnnotation objects (must be sorted by start_offset)147148    Returns:149        HTML with ruby annotations: <ruby>神<rt>かみ</rt></ruby>は<ruby>愛<rt>あい</rt></ruby>です150151    Example:152        >>> annotations = [153        ...     FuriganaAnnotation(0, 1, "神", "かみ"),154        ...     FuriganaAnnotation(2, 3, "愛", "あい"),155        ... ]156        >>> format_furigana_html("神は愛です", annotations)157        '<ruby>神<rt>かみ</rt></ruby>は<ruby>愛<rt>あい</rt></ruby>です'158    """159    if not annotations:160        return plain_text161162    # Sort annotations by start offset163    sorted_annotations = sorted(annotations, key=lambda a: a.start_offset)164165    parts: list[str] = []166    last_end = 0167168    for annotation in sorted_annotations:169        # Add text before this annotation170        parts.append(plain_text[last_end : annotation.start_offset])171172        # Add the ruby element173        parts.append(f"<ruby>{annotation.base_text}<rt>{annotation.reading}</rt></ruby>")174175        last_end = annotation.end_offset176177    # Add remaining text178    parts.append(plain_text[last_end:])179180    return "".join(parts)181182183def validate_furigana_text(text_with_furigana: str) -> list[str]:184    """Validate furigana notation in text.185186    Args:187        text_with_furigana: Text containing inline furigana in bracket notation188189    Returns:190        List of validation error messages (empty if valid)191192    Example:193        >>> validate_furigana_text("神[かみ]は愛[あい]です")194        []195        >>> validate_furigana_text("神[かみは愛[あい]です")  # Unmatched bracket196        ['Unmatched opening bracket at position 1']197    """198    errors: list[str] = []199200    # Check for unmatched brackets. A stack of open positions names the bracket201    # that was actually left open; counting brackets after each "[" blamed the202    # first one whenever any later "[" existed (漢[かん]字[じ reported 1, not 6).203    open_positions: list[int] = []204    for i, char in enumerate(text_with_furigana):205        if char == "[":206            if open_positions:207                errors.append(f"Nested bracket at position {i}")208            open_positions.append(i)209        elif char == "]":210            if not open_positions:211                errors.append(f"Unmatched closing bracket at position {i}")212            else:213                open_positions.pop()214215    if open_positions:216        errors.append(f"Unmatched opening bracket at position {open_positions[0]}")217218    # Check for invalid patterns (non-kanji before brackets, non-kana in brackets)219    # Pattern: something other than kanji followed by [220    invalid_base = re.compile(r"([^一-龯々])\[[ぁ-んァ-ヶー]+\]")221    for match in invalid_base.finditer(text_with_furigana):222        errors.append(223            f"Non-kanji character '{match.group(1)}' before furigana bracket at position {match.start()}"224        )225226    return errors227228229def extract_all_readings(text_with_furigana: str) -> dict[str, list[str]]:230    """Extract all kanji-reading pairs from text.231232    Args:233        text_with_furigana: Text containing inline furigana234235    Returns:236        Dictionary mapping kanji to list of readings found237238    Example:239        >>> extract_all_readings("神[かみ]は神[しん]の愛[あい]")240        {'神': ['かみ', 'しん'], '愛': ['あい']}241    """242    readings: dict[str, list[str]] = {}243244    for match in FURIGANA_PATTERN.finditer(text_with_furigana):245        base_text = match.group(1)246        reading = match.group(2)247248        if base_text not in readings:249            readings[base_text] = []250        if reading not in readings[base_text]:251            readings[base_text].append(reading)252253    return readings254