Initial local project snapshot.

This commit is contained in:
yiulix
2026-06-15 11:42:58 +08:00
commit 1d1f7591db
19 changed files with 7312 additions and 0 deletions
+312
View File
@@ -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),
};
});
}