import GraphemeSplitter from 'grapheme-splitter'; /** * fallback 策略:使用 grapheme-splitter 分割 grapheme clusters。 * * 选择原因: * - 避免手写不完整的 Unicode 规则导致“漏拆/误拆” * - 作为 Intl.Segmenter 不可用时的稳定兜底 * * 注意: * - grapheme-splitter 对少数较新的 emoji ZWJ 序列支持可能不完整(例如 `😮‍💨`)。 * - 为满足本项目“至少不拆 ZWJ sequence/VS16/肤色修饰符/组合字符”的最低要求,这里做一层确定性的后处理合并。 */ export function segmentWithFallback(text: string): string[] { const splitter = new GraphemeSplitter(); const raw = splitter.splitGraphemes(text); return mergeFallbackClusters(raw); } const ZWJ = '\u200D'; function firstCodePoint(s: string): number | null { if (!s) return null; const cp = s.codePointAt(0); return typeof cp === 'number' ? cp : null; } function isVariationSelector(cp: number): boolean { // VS15/VS16 return cp === 0xfe0e || cp === 0xfe0f; } function isSkinToneModifier(cp: number): boolean { return cp >= 0x1f3fb && cp <= 0x1f3ff; } function isCombiningMark(cp: number): boolean { // 常见 combining marks 范围(覆盖 `e\u0301` 等) return ( (cp >= 0x0300 && cp <= 0x036f) || (cp >= 0x1ab0 && cp <= 0x1aff) || (cp >= 0x1dc0 && cp <= 0x1dff) || (cp >= 0x20d0 && cp <= 0x20ff) || (cp >= 0xfe20 && cp <= 0xfe2f) ); } /** * 对 fallback 输出做“最低可用”合并: * - 若上一个 cluster 以 ZWJ 结尾,则必须与下一个合并(ZWJ sequence) * - 若下一个 cluster 以 VS/肤色修饰符/combining mark 开头,也与上一个合并 * * 目标:满足文档列出的“不拆”组合要求,且行为完全确定性。 */ function mergeFallbackClusters(raw: string[]): string[] { if (!raw.length) return raw; const out: string[] = []; let buf = raw[0] ?? ''; for (let i = 1; i < raw.length; i++) { const next = raw[i] ?? ''; const nextCp = firstCodePoint(next); const shouldMerge = buf.endsWith(ZWJ) || (nextCp !== null && (isVariationSelector(nextCp) || isSkinToneModifier(nextCp) || isCombiningMark(nextCp))); if (shouldMerge) { buf += next; continue; } out.push(buf); buf = next; } out.push(buf); return out; }