Initial local project snapshot.
This commit is contained in:
+312
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* @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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user