forked from akai/readest
114 lines
3.0 KiB
TypeScript
114 lines
3.0 KiB
TypeScript
type TTSMark = {
|
|
offset: number;
|
|
name: string;
|
|
text: string;
|
|
language?: string;
|
|
};
|
|
|
|
const cleanTextContent = (text: string) =>
|
|
text.replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ').trimStart();
|
|
|
|
export const parseSSMLMarks = (ssml: string) => {
|
|
const speakMatch = ssml.match(/<speak[^>]*xml:lang="([^"]+)"[^>]*>/i);
|
|
const defaultLang = speakMatch?.[1] ?? 'en';
|
|
ssml = ssml.replace(/<speak[^>]*>/i, '').replace(/<\/speak>/i, '');
|
|
|
|
let plainText = '';
|
|
const marks: TTSMark[] = [];
|
|
|
|
let activeMark: string | null = null;
|
|
let currentLang = defaultLang;
|
|
const langStack: string[] = [];
|
|
|
|
const tagRegex = /<(\/?)(\w+)([^>]*)>|([^<]+)/g;
|
|
|
|
let match: RegExpExecArray | null;
|
|
while ((match = tagRegex.exec(ssml)) !== null) {
|
|
if (match[4]) {
|
|
const rawText = match[4];
|
|
const text = cleanTextContent(rawText);
|
|
if (text && activeMark) {
|
|
const offset = plainText.length;
|
|
plainText += text;
|
|
marks.push({
|
|
offset,
|
|
name: activeMark,
|
|
text,
|
|
language: inferLangFromScript(text, currentLang) || currentLang,
|
|
});
|
|
} else {
|
|
plainText += cleanTextContent(rawText);
|
|
}
|
|
} else {
|
|
const isEnd = match[1] === '/';
|
|
const tagName = match[2];
|
|
const attr = match[3];
|
|
|
|
if (tagName === 'mark' && !isEnd) {
|
|
const nameMatch = attr?.match(/name="([^"]+)"/);
|
|
if (nameMatch) {
|
|
activeMark = nameMatch[1]!;
|
|
}
|
|
} else if (tagName === 'lang') {
|
|
if (!isEnd) {
|
|
langStack.push(currentLang);
|
|
const langMatch = attr?.match(/xml:lang="([^"]+)"/);
|
|
if (langMatch) {
|
|
currentLang = langMatch[1]!;
|
|
}
|
|
} else {
|
|
currentLang = langStack.pop() ?? defaultLang;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { plainText, marks };
|
|
};
|
|
|
|
export const findSSMLMark = (charIndex: number, marks: TTSMark[]) => {
|
|
let left = 0;
|
|
let right = marks.length - 1;
|
|
let result: TTSMark | null = null;
|
|
|
|
while (left <= right) {
|
|
const mid = Math.floor((left + right) / 2);
|
|
const mark = marks[mid]!;
|
|
|
|
if (mark.offset <= charIndex) {
|
|
result = mark;
|
|
left = mid + 1;
|
|
} else {
|
|
right = mid - 1;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const inferLangFromScript = (text: string, lang: string | null): string | null => {
|
|
if (!lang || lang.startsWith('en')) {
|
|
if (/[\p{Script=Hangul}]/u.test(text)) {
|
|
return 'ko';
|
|
} else if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) {
|
|
return 'ja';
|
|
} else if (/[\p{Script=Han}]/u.test(text)) {
|
|
return 'zh';
|
|
}
|
|
}
|
|
return lang;
|
|
};
|
|
|
|
export const parseSSMLLang = (ssml: string): string | null => {
|
|
let lang = null;
|
|
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
|
|
if (match && match[1]) {
|
|
const parts = match[1].split('-');
|
|
lang =
|
|
parts.length > 1
|
|
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
|
|
: parts[0]!.toLowerCase();
|
|
}
|
|
return inferLangFromScript(ssml, lang);
|
|
};
|