@minto-ai/text-stream-slicer
Version:
实时将 Markdown 文本流智能切分为可朗读句子。
304 lines (303 loc) • 10.6 kB
JavaScript
var o = Object.defineProperty;
var I = (r, t, e) => t in r ? o(r, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : r[t] = e;
var i = (r, t, e) => I(r, typeof t != "symbol" ? t + "" : t, e);
var h = /* @__PURE__ */ ((r) => (r.SPACE_CHAR = "spaceChar", r.CHINESE_CHAR = "chineseChar", r.CHINESE_PUNCTUATION = "chinesePunctuation", r.ENGLISH_WORD = "englishWord", r.ENGLISH_PUNCTUATION = "englishPunctuation", r.NUMBER = "number", r.UNKNOWN_CHAR = "unknownChar", r))(h || {});
class d {
constructor() {
i(this, "CHINESE_CHAR_REGEX", /[\u4E00-\u9FA5]/);
i(this, "CHINESE_PUNCTUATION_REGEX", /[,。!?;:、"'()—…·]/);
i(this, "CHINESE_PARAGRAPH_END_REGEX", /[。!?;]/);
i(this, "ENGLISH_WORD_START_REGEX", /[a-z]/i);
i(this, "ENGLISH_WORD_CONTINUE_REGEX", /[a-z'’-]/i);
i(this, "ENGLISH_PUNCTUATION_REGEX", /[.,!?;:'"()-]/);
i(this, "ENGLISH_PARAGRAPH_END_REGEX", /[.!?;]/);
i(this, "SPACE_CHAR_REGEX", /\s/);
i(this, "DIGIT_REGEX", /\d/);
i(this, "DECIMAL_REGEX", /[\d.]/);
/**
* 成对闭合符:包括中英文括号、引号、书名号等
* 中文:)】》』」’”
* 英文:")']}
*/
i(this, "TRAILING_CLOSER_REGEX", /[)"'\]}】》』」’”]/);
i(this, "MIN_PARAGRAPH_LENGTH", 10);
i(this, "pendingTokens", []);
}
/**
* 将输入文本转换为文本标记。
*
* 标记化规则:
* - 中文字符:单个字符作为一个标记
* - 中文标点:单个标点作为一个标记
* - 英文单词:连续英文字母,支持缩写撇号('’)和连字符(-)
* - 数字序列:支持小数点,如 22.5 作为一个完整数字标记
* - 空白字符:空格、制表符等作为一个标记
* - 其他字符:作为未知类型标记
*
* @param text 原始文本
* @returns 标记化后的文本标记数组
*/
textToTokens(t) {
const e = [];
let n = 0;
for (; n < t.length; ) {
const s = t[n];
if (this.CHINESE_CHAR_REGEX.test(s))
e.push({
type: h.CHINESE_CHAR,
value: s
});
else if (this.DIGIT_REGEX.test(s)) {
const l = n;
let a = n + 1;
for (; a < t.length && this.DECIMAL_REGEX.test(t[a]); )
a++;
const T = t.substring(l, a);
e.push({
type: h.NUMBER,
value: T
}), n += T.length - 1;
} else if (this.ENGLISH_WORD_START_REGEX.test(s)) {
const l = n;
let a = n + 1;
for (; a < t.length && this.ENGLISH_WORD_CONTINUE_REGEX.test(t[a]); )
a++;
const T = t.substring(l, a);
e.push({
type: h.ENGLISH_WORD,
value: T
}), n += T.length - 1;
} else this.CHINESE_PUNCTUATION_REGEX.test(s) ? e.push({
type: h.CHINESE_PUNCTUATION,
value: s
}) : this.ENGLISH_PUNCTUATION_REGEX.test(s) ? e.push({
type: h.ENGLISH_PUNCTUATION,
value: s
}) : this.SPACE_CHAR_REGEX.test(s) ? e.push({
type: h.SPACE_CHAR,
value: s
}) : e.push({
type: h.UNKNOWN_CHAR,
value: s
});
n++;
}
return e;
}
/**
* 文本标记转换为段落,并更新 pendingTokens。
*
* 跨次调用缓存逻辑:
* 1. 先将前次剩余标记(pendingTokens)与本次新标记拼接
* 2. 对拼接后的完整标记序列进行切分判断
* 3. 未达切分条件的标记缓存到 pendingTokens 供下次使用
*
* @param tokens 文本标记数组
* @returns 段落数组(每段为 Token[])
*/
tokenToParagraphs(t) {
const e = this.pendingTokens.length > 0 ? [...this.pendingTokens, ...t] : [...t];
this.pendingTokens = [];
const n = [];
let s = [], l = 0;
for (; l < e.length; ) {
const a = e[l];
if (s.push(a), s.length >= this.MIN_PARAGRAPH_LENGTH && (this.CHINESE_PARAGRAPH_END_REGEX.test(a.value) || this.ENGLISH_PARAGRAPH_END_REGEX.test(a.value))) {
let T = l + 1;
for (; T < e.length; ) {
const g = e[T];
if (g.type === h.SPACE_CHAR || this.TRAILING_CLOSER_REGEX.test(g.value))
s.push(g), T++;
else
break;
}
n.push([...s]), s = [], l = T - 1;
}
l += 1;
}
return s.length > 0 && (this.pendingTokens = [...s]), n;
}
/**
* 根据输入文本构建段落列表,并在必要时输出剩余标记。
*
* @param text 输入文本
* @param includeRemaining 是否包含待处理的剩余标记
* @returns 段落数组(每段为字符串)
*/
build(t, e) {
const n = this.textToTokens(t), s = this.tokenToParagraphs(n);
return e && this.pendingTokens.length > 0 && (s.push([...this.pendingTokens]), this.pendingTokens = []), s.map((a) => a.map((T) => T.value).join(""));
}
/**
* 重置段落构建器,清空内部 pendingTokens 缓存。
* 用于单例模式下重复调用时确保状态干净。
*/
reset() {
this.pendingTokens = [];
}
}
let E = null;
function N() {
return E !== null ? (E.reset(), E) : (E = new d(), E);
}
class A {
constructor() {
// 缓存未完成结构的文本片段,等待后续输入。
i(this, "pendingText", "");
i(this, "MD_LINK_TAIL_PATTERNS", [/\[$/, /\[[^\]]*$/, /\[[^\]]*\]$/, /\[[^\]]*\]\($/, /\[[^\]]*\]\([^)]*$/, /\[[^\]]*\]\([^)]*\)$/]);
i(this, "NUMBER_TAIL_PATTERNS", [/\d+$/, /\d+\.$/, /\d+\.\d+$/]);
i(this, "IMG_TAG_TAIL_PATTERNS", [/((<)|(<i)|(<im)|(<img))$/, /<img$/, /<img[^>]*$/, /<img[^>]*>$/, /<img[^>]*\/$/, /<img[^>]*\/>$/]);
i(this, "VIDEO_TAG_TAIL_PATTERNS", [/((<)|(<v)|(<vi)|(<vid)|(<vide)|(<video))$/, /((<\/v)|(<\/vi)|(<\/vid)|(<\/vide)|(<\/video))$/, /<video$/, /<video[^>]*$/, /<video[^>]*>$/, /<video[^>]*><\/$/, /<video[^>]*><\/video$/, /<video[^>]*><\/video>$/]);
}
/**
* 判断片段末尾是否处在 Markdown 链接结构中(未完成或完整)。
*
* @param text 输入文本。
* @returns 是否在链接尾部。
*/
isMarkdownLinkTail(t) {
return this.MD_LINK_TAIL_PATTERNS.some((e) => e.test(t));
}
/**
* 判断片段末尾是否是数字或小数的一部分(未完成或完整)。
*
* @param text 输入文本。
* @returns 是否命中数字尾部。
*/
isNumberTail(t) {
return this.NUMBER_TAIL_PATTERNS.some((e) => e.test(t));
}
/**
* 判断片段末尾是否处在 CSS img 标签结构中(未完成或完整)。
*
* @param text 输入文本。
* @returns 是否命中 img 标签尾部。
*/
isImgTagTail(t) {
return this.IMG_TAG_TAIL_PATTERNS.some((e) => e.test(t));
}
/**
* 判断片段末尾是否处在 HTML video 标签结构中(未完成或完整)。
*
* @param text 输入文本。
* @returns 是否命中 video 标签尾部。
*/
isVideoTagTail(t) {
return this.VIDEO_TAG_TAIL_PATTERNS.some((e) => e.test(t));
}
/**
* 合并缓存并判断是否需要继续缓存当前片段,以避免在未完成结构处切句。
*
* @param text 当前输入片段。
* @param includeRemaining 是否包含待处理的剩余文本。
* @returns 预处理结果:若触发缓存则返回空字符串与新的缓存;否则返回合并后的文本与空缓存。
*/
preprocess(t, e) {
let n = "";
return this.pendingText.length > 0 ? n = `${this.pendingText}${t}` : n = t, e ? (this.pendingText = "", {
processedText: n,
nextPendingText: ""
}) : this.isMarkdownLinkTail(n) ? (this.pendingText = n, {
processedText: "",
nextPendingText: n
}) : this.isNumberTail(n) ? (this.pendingText = n, {
processedText: "",
nextPendingText: n
}) : this.isImgTagTail(n) ? (this.pendingText = n, {
processedText: "",
nextPendingText: n
}) : this.isVideoTagTail(n) ? (this.pendingText = n, {
processedText: "",
nextPendingText: n
}) : (this.pendingText = "", {
processedText: n,
nextPendingText: ""
});
}
}
let _ = null;
function R() {
return _ !== null || (_ = new A()), _;
}
class P {
/**
* 执行语义替换。
* @param {string} text 输入文本
* @returns {string} 替换后的文本
*/
replace(t) {
let e = t;
return e = e.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1"), e = e.replace(/<img[^>]*\/>/g, ""), e = e.replace(/<img[^>]*>/g, ""), e = e.replace(/<video[^>]*><\/video\s*>/g, ""), e;
}
}
let p = null;
function G() {
return p !== null || (p = new P()), p;
}
class x {
/**
* 清洗段落数组。
*
* 处理流程:
* 1. 去除每段首尾空白字符;
* 2. 过滤空字符串;
* 3. 过滤仅含标点/符号/空白而无字母、数字或中文的“无意义段”。
*
* @param paragraphs 原始段落数组
* @returns 清洗后的段落数组
*/
sanitize(t) {
return t.map((e) => e.trim()).filter((e) => e.length > 0).filter((e) => /[\p{L}\p{N}]/u.test(e));
}
}
let u = null;
function C() {
return u !== null || (u = new x()), u;
}
class S {
/**
* 清洗输入文本,按需移除特定字符。
*
* @param text 输入文本
* @returns 规范化后的文本
*/
sanitize(t) {
let e = t;
return e = e.replace(/\*+/g, ""), e = e.replace(/#+/g, ""), e = e.replace(/[\u{1F000}-\u{1FAFF}]/gu, ""), e;
}
}
let c = null;
function H() {
return c !== null || (c = new S()), c;
}
class f {
constructor() {
// 文本去噪器:移除非法字符与多余空白
i(this, "sanitizeTextInstance", H());
// 文本预处理器:处理尾部歧义并缓存中间结果
i(this, "preprocessTextInstance", R());
// 文本替换器:按规则替换特殊语义单元
i(this, "replaceTextInstance", G());
// 段落构建器:按句末标点与长度规则拆分段落
i(this, "paragraphsInstance", N());
// 段落清洗器:最终过滤空段与无意义段
i(this, "sanitizeParagraphsInstance", C());
}
/**
* 同步处理输入文本片段,返回已分段的文本数组
*
* @param text 本次输入的文本片段
* @param includeRemaining 是否已无后续文本
* @returns 分段后的段落数组
*/
processText(t, e = !1) {
const n = this.sanitizeTextInstance.sanitize(t), {
processedText: s
} = this.preprocessTextInstance.preprocess(n, e), l = this.replaceTextInstance.replace(s), a = this.paragraphsInstance.build(l, e);
return this.sanitizeParagraphsInstance.sanitize(a);
}
}
export {
f as TextStreamSplitter,
f as default
};