313 lines
9.8 KiB
TypeScript
313 lines
9.8 KiB
TypeScript
/**
|
||
* @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),
|
||
};
|
||
});
|
||
}
|
||
|