/** * @license * SPDX-License-Identifier: Apache-2.0 */ import { FuriganaItem, LineSegment, SidebarNote, WritingLine } from './types'; // Regex to check if a character is Kanji, including common iteration marks like 々, 〆, ヶ export const KANJI_REGEX = /[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF々〆ヶ]/; /** * Parses raw text containing custom ruby bindings into LineSegments. * Supported syntaxes: * 1. Bracket explicit syntax: [漢字]{かんじ} or [日本語]{にほんご} * 2. Auto-Kanji binding sync: 漢字{かんじ} (automatically binds to consecutive kanji characters preceding the brace) * 3. Fallback binding: 文字{もじ} (for non-kanji, binds to the sequence preceding `{`) */ export function parseRawTextToSegments(rawText: string): LineSegment[] { const segments: LineSegment[] = []; if (!rawText) return segments; const appendPlainText = (text: string) => { if (!text) return; const lastSegment = segments[segments.length - 1]; if (lastSegment?.type === 'text') { lastSegment.text += text; } else { segments.push({ type: 'text', text }); } }; let i = 0; const len = rawText.length; while (i < len) { // 1. Explicit bracket syntax: [base]{rt} if (rawText[i] === '[') { const closeBracketIndex = rawText.indexOf(']', i); if (closeBracketIndex !== -1 && rawText[closeBracketIndex + 1] === '{') { const closeBraceIndex = rawText.indexOf('}', closeBracketIndex + 1); if (closeBraceIndex !== -1) { const baseText = rawText.slice(i + 1, closeBracketIndex); const rtText = rawText.slice(closeBracketIndex + 2, closeBraceIndex); segments.push({ type: 'ruby', text: baseText, rt: rtText }); i = closeBraceIndex + 1; continue; } } appendPlainText(rawText[i]); i++; continue; } // 2. Simple brace syntax: TEXT{rt} if (rawText[i] === '{') { const closeBraceIndex = rawText.indexOf('}', i); const baseStartProbe = i - 1; if (closeBraceIndex !== -1 && baseStartProbe >= 0) { const rtText = rawText.slice(i + 1, closeBraceIndex); let baseStart = baseStartProbe; const lastChar = rawText[baseStart]; if (KANJI_REGEX.test(lastChar)) { while (baseStart > 0 && KANJI_REGEX.test(rawText[baseStart - 1])) { baseStart--; } } else { const stopChars = /[\s{}·・,。、!¥…\-[\]]/; while (baseStart > 0 && !stopChars.test(rawText[baseStart - 1])) { baseStart--; } } const baseText = rawText.slice(baseStart, i); if (baseText) { let lengthToRemove = baseText.length; while (lengthToRemove > 0 && segments.length > 0) { const lastSegment = segments[segments.length - 1]; if (lastSegment.type !== 'text') { break; } if (lastSegment.text.length <= lengthToRemove) { lengthToRemove -= lastSegment.text.length; segments.pop(); } else { lastSegment.text = lastSegment.text.slice(0, -lengthToRemove); lengthToRemove = 0; } } segments.push({ type: 'ruby', text: baseText, rt: rtText }); i = closeBraceIndex + 1; continue; } } appendPlainText(rawText[i]); i++; continue; } let plainText = ''; while (i < len && rawText[i] !== '[' && rawText[i] !== '{') { plainText += rawText[i]; i++; } appendPlainText(plainText); } return segments; } /** * Converts line segments back into raw mark-up string. */ export function segmentsToRawText(segments: LineSegment[]): string { return segments .map((s) => { if (s.type === 'ruby') { // Use simpler auto-binding syntax if base is pure Kanji, otherwise use bracket syntax [base]{rt} const isPureKanji = [...s.text].every((char) => KANJI_REGEX.test(char)); if (isPureKanji) { return `${s.text}{${s.rt || ''}}`; } else { return `[${s.text}]{${s.rt || ''}}`; } } return s.text; }) .join(''); } /** * Helper to check if a string contains any Kanji. */ export function containsKanji(text: string): boolean { return [...text].some((char) => KANJI_REGEX.test(char)); } /** * Parses legacy text with curly braces (e.g. 日本語{にほんご}或者吾輩{わがはい}は猫{ねこ}である) * into clean main text plus freely positioned furigana items. */ export function convertLegacyText(rawText: string): { main: string; furigana: string; furiganaItems: FuriganaItem[] } { const segments = parseRawTextToSegments(rawText); let mainText = ''; const furiganaItems: FuriganaItem[] = []; for (const seg of segments) { if (seg.type === 'ruby') { const baseText = seg.text; const rtText = seg.rt || ''; const startIndex = mainText.length; mainText += baseText; if (rtText) { furiganaItems.push({ id: `ruby-${furiganaItems.length}-${startIndex}`, text: rtText, x: startIndex, y: 12, }); } } else { mainText += seg.text; } } return { main: mainText, furigana: '', furiganaItems }; } const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); export function migrateLegacyFuriganaTextToItems(rawText: string, furiganaText: string): FuriganaItem[] { const items: FuriganaItem[] = []; const pattern = /[^\s ]+/g; let match: RegExpExecArray | null; while ((match = pattern.exec(furiganaText)) !== null) { const text = match[0].trim(); if (!text) continue; const startIndex = Math.min(match.index, Math.max(rawText.length, 1)); items.push({ id: `legacy-${items.length}-${startIndex}`, text, x: startIndex, y: 12, }); } return items; } export function sanitizeFuriganaItems(value: unknown): FuriganaItem[] { if (!Array.isArray(value)) return []; return value .map((item, index) => { if (!item || typeof item !== 'object') return null; const candidate = item as Partial; const text = typeof candidate.text === 'string' ? candidate.text : ''; return { id: typeof candidate.id === 'string' && candidate.id ? candidate.id : `furi-${index}`, text, x: typeof candidate.x === 'number' && Number.isFinite(candidate.x) ? Math.max(0, candidate.x) : 0, y: typeof candidate.y === 'number' && Number.isFinite(candidate.y) ? clamp(candidate.y, 0, 80) : 12, }; }) .filter((item): item is FuriganaItem => !!item && item.text.trim().length > 0); } const sanitizeNotes = (value: unknown): SidebarNote[] => { if (!Array.isArray(value)) return []; return value .map((note, index) => { if (!note || typeof note !== 'object') return null; const candidate = note as Partial; return { id: typeof candidate.id === 'string' && candidate.id ? candidate.id : `note-${Date.now()}-${index}`, text: typeof candidate.text === 'string' ? candidate.text : '', comment: typeof candidate.comment === 'string' ? candidate.comment : '', color: typeof candidate.color === 'string' ? candidate.color : 'yellow', createdAt: typeof candidate.createdAt === 'number' ? candidate.createdAt : Date.now(), }; }) .filter((note): note is SidebarNote => !!note && note.text.trim().length > 0); }; export function normalizeLine(value: Partial & { rawText?: unknown }, fallbackId: string): WritingLine { const rawTextInput = typeof value.rawText === 'string' ? value.rawText : ''; const legacyFuriganaText = typeof value.furiganaText === 'string' ? value.furiganaText : ''; const parsed = convertLegacyText(rawTextInput); const hasMarkup = parsed.furiganaItems.length > 0 || parsed.main !== rawTextInput; const rawText = hasMarkup ? parsed.main : rawTextInput; const providedItems = sanitizeFuriganaItems(value.furiganaItems); const furiganaItems = hasMarkup ? providedItems.length > 0 ? providedItems : parsed.furiganaItems : providedItems.length > 0 ? providedItems : migrateLegacyFuriganaTextToItems(rawText, legacyFuriganaText); return { id: typeof value.id === 'string' && value.id ? value.id : fallbackId, rawText, furiganaText: '', furiganaItems, segments: [], notes: sanitizeNotes(value.notes), }; } export function normalizeLines(value: unknown, idPrefix = 'line'): WritingLine[] { if (!Array.isArray(value)) return []; return value.map((line, index) => normalizeLine((line && typeof line === 'object' ? line : {}) as Partial, `${idPrefix}-${Date.now()}-${index}`) ); } export function adjustFuriganaItemsForTextChange( previousText: string, nextText: string, items: FuriganaItem[] ): FuriganaItem[] { if (previousText === nextText || items.length === 0) return items; let prefixLength = 0; while ( prefixLength < previousText.length && prefixLength < nextText.length && previousText[prefixLength] === nextText[prefixLength] ) { prefixLength++; } let previousSuffix = previousText.length - 1; let nextSuffix = nextText.length - 1; while ( previousSuffix >= prefixLength && nextSuffix >= prefixLength && previousText[previousSuffix] === nextText[nextSuffix] ) { previousSuffix--; nextSuffix--; } const removedLength = Math.max(0, previousSuffix - prefixLength + 1); const insertedLength = Math.max(0, nextSuffix - prefixLength + 1); const delta = insertedLength - removedLength; if (delta === 0) return items; return items.map((item) => { if (item.x < prefixLength) return item; return { ...item, x: Math.max(0, item.x + delta), }; }); }