OPEN SOURCE
software/src/utils/furigana.py
このサイトで読み、必要なファイルを保存できます。
日常訳はAIを用いて作成された翻訳です。人間の聖書学・原語の専門家による全訳の検証は確認されていません。既存の翻訳や原語資料と照らし合わせてお読みください。
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.2324Attributes:25start_offset: Character position in the plain text (0-indexed)26end_offset: End position in the plain text (exclusive)27base_text: The kanji characters (e.g., "神")28reading: The hiragana reading (e.g., "かみ")29is_standard_reading: Whether this is a standard dictionary reading30reading_source: Source of the reading ("dictionary", "context", "theological")31"""3233start_offset: int34end_offset: int35base_text: str36reading: str37is_standard_reading: bool = True38reading_source: str = "dictionary"394041def parse_furigana(text_with_furigana: str) -> tuple[str, list[FuriganaAnnotation]]:42"""Parse inline furigana notation and extract annotations.4344Takes text in the format: 神[かみ]は愛[あい]です45Returns the plain text and a list of FuriganaAnnotation objects.4647Args:48text_with_furigana: Text containing inline furigana in bracket notation4950Returns:51Tuple 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_text5455Example:56>>> plain, annotations = parse_furigana("神[かみ]は愛[あい]です")57>>> plain58'神は愛です'59>>> annotations[0]60FuriganaAnnotation(start_offset=0, end_offset=1, base_text='神', reading='かみ', ...)61>>> annotations[1]62FuriganaAnnotation(start_offset=2, end_offset=3, base_text='愛', reading='あい', ...)63"""64annotations: list[FuriganaAnnotation] = []65plain_parts: list[str] = []66plain_offset = 067last_end = 06869for match in FURIGANA_PATTERN.finditer(text_with_furigana):70# Add text before this match71plain_parts.append(text_with_furigana[last_end : match.start()])72plain_offset += match.start() - last_end7374# Extract the kanji and reading75base_text = match.group(1)76reading = match.group(2)7778# Create annotation with offset in plain text79annotation = FuriganaAnnotation(80start_offset=plain_offset,81end_offset=plain_offset + len(base_text),82base_text=base_text,83reading=reading,84)85annotations.append(annotation)8687# Add the base text to plain output88plain_parts.append(base_text)89plain_offset += len(base_text)90last_end = match.end()9192# Add remaining text after last match93plain_parts.append(text_with_furigana[last_end:])94plain_text = "".join(plain_parts)9596return plain_text, annotations979899def format_furigana_inline(plain_text: str, annotations: list[FuriganaAnnotation]) -> str:100"""Convert plain text and annotations back to inline furigana format.101102Args:103plain_text: Plain Japanese text without furigana104annotations: List of FuriganaAnnotation objects (must be sorted by start_offset)105106Returns:107Text with inline furigana notation: 神[かみ]は愛[あい]です108109Example:110>>> annotations = [111... FuriganaAnnotation(0, 1, "神", "かみ"),112... FuriganaAnnotation(2, 3, "愛", "あい"),113... ]114>>> format_furigana_inline("神は愛です", annotations)115'神[かみ]は愛[あい]です'116"""117if not annotations:118return plain_text119120# Sort annotations by start offset121sorted_annotations = sorted(annotations, key=lambda a: a.start_offset)122123parts: list[str] = []124last_end = 0125126for annotation in sorted_annotations:127# Add text before this annotation128parts.append(plain_text[last_end : annotation.start_offset])129130# Add the annotated text with furigana131parts.append(f"{annotation.base_text}[{annotation.reading}]")132133last_end = annotation.end_offset134135# Add remaining text136parts.append(plain_text[last_end:])137138return "".join(parts)139140141def format_furigana_html(plain_text: str, annotations: list[FuriganaAnnotation]) -> str:142"""Convert plain text and annotations to HTML ruby format.143144Args:145plain_text: Plain Japanese text without furigana146annotations: List of FuriganaAnnotation objects (must be sorted by start_offset)147148Returns:149HTML with ruby annotations: <ruby>神<rt>かみ</rt></ruby>は<ruby>愛<rt>あい</rt></ruby>です150151Example: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"""159if not annotations:160return plain_text161162# Sort annotations by start offset163sorted_annotations = sorted(annotations, key=lambda a: a.start_offset)164165parts: list[str] = []166last_end = 0167168for annotation in sorted_annotations:169# Add text before this annotation170parts.append(plain_text[last_end : annotation.start_offset])171172# Add the ruby element173parts.append(f"<ruby>{annotation.base_text}<rt>{annotation.reading}</rt></ruby>")174175last_end = annotation.end_offset176177# Add remaining text178parts.append(plain_text[last_end:])179180return "".join(parts)181182183def validate_furigana_text(text_with_furigana: str) -> list[str]:184"""Validate furigana notation in text.185186Args:187text_with_furigana: Text containing inline furigana in bracket notation188189Returns:190List of validation error messages (empty if valid)191192Example:193>>> validate_furigana_text("神[かみ]は愛[あい]です")194[]195>>> validate_furigana_text("神[かみは愛[あい]です") # Unmatched bracket196['Unmatched opening bracket at position 1']197"""198errors: 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).203open_positions: list[int] = []204for i, char in enumerate(text_with_furigana):205if char == "[":206if open_positions:207errors.append(f"Nested bracket at position {i}")208open_positions.append(i)209elif char == "]":210if not open_positions:211errors.append(f"Unmatched closing bracket at position {i}")212else:213open_positions.pop()214215if open_positions:216errors.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 [220invalid_base = re.compile(r"([^一-龯々])\[[ぁ-んァ-ヶー]+\]")221for match in invalid_base.finditer(text_with_furigana):222errors.append(223f"Non-kanji character '{match.group(1)}' before furigana bracket at position {match.start()}"224)225226return errors227228229def extract_all_readings(text_with_furigana: str) -> dict[str, list[str]]:230"""Extract all kanji-reading pairs from text.231232Args:233text_with_furigana: Text containing inline furigana234235Returns:236Dictionary mapping kanji to list of readings found237238Example:239>>> extract_all_readings("神[かみ]は神[しん]の愛[あい]")240{'神': ['かみ', 'しん'], '愛': ['あい']}241"""242readings: dict[str, list[str]] = {}243244for match in FURIGANA_PATTERN.finditer(text_with_furigana):245base_text = match.group(1)246reading = match.group(2)247248if base_text not in readings:249readings[base_text] = []250if reading not in readings[base_text]:251readings[base_text].append(reading)252253return readings254