82 lines
2.3 KiB
TypeScript
82 lines
2.3 KiB
TypeScript
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;
|
||
}
|
||
|