Files
kotonoha/src/utils.ts
T
2026-06-15 11:42:58 +08:00

313 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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<FuriganaItem>;
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<SidebarNote>;
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<WritingLine> & { 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<WritingLine>, `${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),
};
});
}