更新换行算法和APP-PUSH

This commit is contained in:
吕新雨
2026-02-10 11:39:33 +08:00
parent f03d36b5e9
commit ee2d9f44ea
105 changed files with 9967 additions and 233 deletions

View File

@@ -0,0 +1,81 @@
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;
}

View File

@@ -0,0 +1,28 @@
/**
* Intl.Segmenter 策略(优先)
*
* 注意:不同 JS 引擎/版本对 Intl.Segmenter 的支持可能不同,调用方必须捕获异常并降级。
*/
export function canUseIntlSegmenter(): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Seg = (globalThis as any)?.Intl?.Segmenter;
return typeof Seg === 'function';
}
export function segmentWithIntlSegmenter(text: string): string[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Segmenter = (globalThis as any).Intl.Segmenter as new (locales?: string | string[], options?: any) => any;
// 用 zh-Hant 仅用于选择合适的 localegrapheme 分割应与语言本身关系不大,但保持固定输入更易对齐跨端。
const seg = new Segmenter('zh-Hant', { granularity: 'grapheme' });
const it = seg.segment(text);
const clusters: string[] = [];
for (const part of it) {
// part: { segment: string, index: number, input: string, isWordLike?: boolean }
clusters.push(part.segment);
}
return clusters;
}