更新换行算法和APP-PUSH
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_PUNCTUATION_STRIP_SET_EN,
|
||||
compareBreaksLexicographically,
|
||||
joinTokens,
|
||||
matchENKeyword,
|
||||
normalizeENKeyword,
|
||||
normalizeWhitespace,
|
||||
tokenizeEN,
|
||||
} from '../index';
|
||||
|
||||
describe('textWrap core-contract', () => {
|
||||
it('normalizeWhitespace(NORMALIZE): 折叠空白 + 去首尾', () => {
|
||||
const out = normalizeWhitespace(' a b \n c ', 'NORMALIZE');
|
||||
expect(out.normalizedText).toBe('a b c');
|
||||
expect(out.hadMultiWhitespace).toBe(true);
|
||||
});
|
||||
|
||||
it('tokenizeEN: 只按空白分词(极简派,不拆标点)', () => {
|
||||
const { normalizedText } = normalizeWhitespace('I am so tired.', 'NORMALIZE');
|
||||
const tokens = tokenizeEN(normalizedText);
|
||||
expect(tokens.map((t) => t.text)).toEqual(['I', 'am', 'so', 'tired.']);
|
||||
});
|
||||
|
||||
it('joinTokens: 默认用单空格重组', () => {
|
||||
const { normalizedText } = normalizeWhitespace('I am so tired', 'NORMALIZE');
|
||||
const tokens = tokenizeEN(normalizedText);
|
||||
expect(joinTokens(tokens, 0, 2)).toBe('I am');
|
||||
expect(joinTokens(tokens, 2, 4)).toBe('so tired');
|
||||
});
|
||||
|
||||
it('normalizeENKeyword/matchENKeyword: 全词等值匹配,不做 substring', () => {
|
||||
const config = { punctuationStripSetEN: DEFAULT_PUNCTUATION_STRIP_SET_EN };
|
||||
|
||||
expect(normalizeENKeyword('BUT,', config)).toBe('but');
|
||||
expect(matchENKeyword('but,', 'but', config)).toBe(true);
|
||||
expect(matchENKeyword('rebuttal', 'but', config)).toBe(false);
|
||||
});
|
||||
|
||||
it('compareBreaksLexicographically: 字典序 + 短数组优先', () => {
|
||||
expect(compareBreaksLexicographically([2], [3])).toBeLessThan(0);
|
||||
expect(compareBreaksLexicographically([2], [2, 5])).toBeLessThan(0);
|
||||
expect(compareBreaksLexicographically([2, 3], [2])).toBeGreaterThan(0);
|
||||
expect(compareBreaksLexicographically([2, 3], [2, 3])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
25
client/src/features/textWrap/core/compare.ts
Normal file
25
client/src/features/textWrap/core/compare.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* breaks[] 字典序比较(确定性口径)
|
||||
*
|
||||
* 规则(必须):
|
||||
* - 从 index=0 起逐项比较:
|
||||
* - 首个不同元素更小者视为更小
|
||||
* - 若公共前缀完全相同:
|
||||
* - 更短的数组视为更小
|
||||
*/
|
||||
export function compareBreaksLexicographically(a: number[], b: number[]): number {
|
||||
const aa = Array.isArray(a) ? a : [];
|
||||
const bb = Array.isArray(b) ? b : [];
|
||||
|
||||
const n = Math.min(aa.length, bb.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = aa[i]!;
|
||||
const y = bb[i]!;
|
||||
if (x === y) continue;
|
||||
return x < y ? -1 : 1;
|
||||
}
|
||||
|
||||
if (aa.length === bb.length) return 0;
|
||||
return aa.length < bb.length ? -1 : 1;
|
||||
}
|
||||
|
||||
68
client/src/features/textWrap/core/enKeyword.ts
Normal file
68
client/src/features/textWrap/core/enKeyword.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { CoreConfig } from './types';
|
||||
|
||||
/**
|
||||
* EN 常见标点(默认集合)
|
||||
*
|
||||
* 说明:此集合只用于“关键词命中判定”的两端 strip,不影响 tokenize(仍为极简派)。
|
||||
* 必须全端一致;后续若扩展,也必须通过 configVersion 管理。
|
||||
*/
|
||||
export const DEFAULT_PUNCTUATION_STRIP_SET_EN: string[] = [
|
||||
',',
|
||||
'.',
|
||||
'!',
|
||||
'?',
|
||||
':',
|
||||
';',
|
||||
'"',
|
||||
"'",
|
||||
'…',
|
||||
'—',
|
||||
'–',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
];
|
||||
|
||||
function buildStripSet(config?: Pick<CoreConfig, 'punctuationStripSetEN'>): Set<string> {
|
||||
const list = config?.punctuationStripSetEN?.length ? config.punctuationStripSetEN : DEFAULT_PUNCTUATION_STRIP_SET_EN;
|
||||
return new Set(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化一个 token,用于 EN 关键词命中(全词等值匹配)。
|
||||
*
|
||||
* 口径(必须):
|
||||
* - lowercase
|
||||
* - strip 两端常见标点(可重复剥离,例如 `"\"but,\""`)
|
||||
* - 不允许 substring/contains
|
||||
*/
|
||||
export function normalizeENKeyword(tokenText: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): string {
|
||||
const stripSet = buildStripSet(config);
|
||||
|
||||
let s = String(tokenText ?? '').toLowerCase();
|
||||
if (!s) return '';
|
||||
|
||||
// 仅剥离两端的“常见标点”,内部字符不处理
|
||||
let start = 0;
|
||||
let end = s.length;
|
||||
|
||||
while (start < end && stripSet.has(s[start]!)) start++;
|
||||
while (end > start && stripSet.has(s[end - 1]!)) end--;
|
||||
|
||||
// 如果剥离后还剩空白,再做一次 trim(避免 `"but "` 这种输入)
|
||||
return s.slice(start, end).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* EN 关键词命中:全词等值匹配(禁止 substring)。
|
||||
*/
|
||||
export function matchENKeyword(tokenText: string, keyword: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): boolean {
|
||||
const a = normalizeENKeyword(tokenText, config);
|
||||
const b = normalizeENKeyword(keyword, config);
|
||||
if (!a || !b) return false;
|
||||
return a === b;
|
||||
}
|
||||
|
||||
8
client/src/features/textWrap/core/index.ts
Normal file
8
client/src/features/textWrap/core/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { CoreConfig, Lang, NormalizeWhitespaceResult, Token, WhitespacePolicy } from './types';
|
||||
|
||||
export { normalizeWhitespace } from './normalizeWhitespace';
|
||||
export { tokenizeEN } from './tokenizeEN';
|
||||
export { joinTokens } from './joinTokens';
|
||||
export { DEFAULT_PUNCTUATION_STRIP_SET_EN, matchENKeyword, normalizeENKeyword } from './enKeyword';
|
||||
export { compareBreaksLexicographically } from './compare';
|
||||
|
||||
37
client/src/features/textWrap/core/joinTokens.ts
Normal file
37
client/src/features/textWrap/core/joinTokens.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Token } from './types';
|
||||
|
||||
/**
|
||||
* 从 token 区间 `[start..end)` 重组回文本(跨端口径)。
|
||||
*
|
||||
* - 默认:用单空格 join(适用于 whitespacePolicy=NORMALIZE)
|
||||
* - 若传入 rawSeparators:按原始分隔符拼接(适用于 whitespacePolicy=PRESERVE)
|
||||
*
|
||||
* 说明:
|
||||
* - `start/end` 为半开区间
|
||||
* - 空区间返回空字符串;上层需要用硬约束避免空行
|
||||
*/
|
||||
export function joinTokens(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
|
||||
const s = Math.max(0, start | 0);
|
||||
const e = Math.max(0, end | 0);
|
||||
if (!Array.isArray(tokens) || tokens.length === 0) return '';
|
||||
if (s >= e) return '';
|
||||
|
||||
const slice = tokens.slice(s, e).map((t) => t.text);
|
||||
if (!rawSeparators) {
|
||||
return slice.join(' ');
|
||||
}
|
||||
|
||||
// rawSeparators[i] 表示 tokens[i] 与 tokens[i+1] 之间的分隔符
|
||||
// 这里只实现最小可用:若 separators 缺失则回退到单空格。
|
||||
let out = '';
|
||||
for (let idx = s; idx < e; idx++) {
|
||||
const t = tokens[idx];
|
||||
if (!t) continue;
|
||||
out += t.text;
|
||||
if (idx < e - 1) {
|
||||
out += rawSeparators[idx] ?? ' ';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
28
client/src/features/textWrap/core/normalizeWhitespace.ts
Normal file
28
client/src/features/textWrap/core/normalizeWhitespace.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { NormalizeWhitespaceResult, WhitespacePolicy } from './types';
|
||||
|
||||
/**
|
||||
* 空白归一化(跨端口径)
|
||||
*
|
||||
* - NORMALIZE:折叠连续空白为单空格,并去首尾空白
|
||||
* - PRESERVE:仅去首尾空白(内部空白保持原样)
|
||||
*
|
||||
* 注意:该函数会改变输入文本(至少会 trim),必须作为“算法契约”的一部分固定下来。
|
||||
*/
|
||||
export function normalizeWhitespace(text: string, policy: WhitespacePolicy = 'NORMALIZE'): NormalizeWhitespaceResult {
|
||||
const input = String(text ?? '');
|
||||
|
||||
if (policy === 'PRESERVE') {
|
||||
const trimmed = input.trim();
|
||||
// 只要发生 trim,或内部存在非单空格的分隔(如 \n/\t/多个空格),都算 hadMultiWhitespace
|
||||
const hadMultiWhitespace = trimmed !== input || /[\t\r\n]/.test(trimmed) || / +/.test(trimmed);
|
||||
return { normalizedText: trimmed, hadMultiWhitespace };
|
||||
}
|
||||
|
||||
// NORMALIZE:把任意连续空白折叠为 1 个空格,并去首尾空白
|
||||
// 说明:这里使用 \s+ 覆盖空格/换行/制表等;跨端需要保证采用同等语义的实现。
|
||||
const trimmed = input.trim();
|
||||
const collapsed = trimmed.replace(/\s+/g, ' ');
|
||||
const hadMultiWhitespace = collapsed !== input;
|
||||
return { normalizedText: collapsed, hadMultiWhitespace };
|
||||
}
|
||||
|
||||
38
client/src/features/textWrap/core/tokenizeEN.ts
Normal file
38
client/src/features/textWrap/core/tokenizeEN.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Token } from './types';
|
||||
|
||||
/**
|
||||
* EN tokenize(极简派)
|
||||
*
|
||||
* 口径:
|
||||
* - tokens 只包含 WORD,不生成 SPACE token
|
||||
* - 标点视为“词内字符”,不额外拆分(例如 "tired."、"Wait..."、"hello—world" 都是一个 token)
|
||||
* - 断点只允许发生在“词与词之间”(由上层生成 breakpoint 时遵守)
|
||||
*
|
||||
* start/end 索引以 normalizedText 为基准(建议先调用 normalizeWhitespace)。
|
||||
*/
|
||||
export function tokenizeEN(normalizedText: string): Token[] {
|
||||
const s = String(normalizedText ?? '');
|
||||
if (!s) return [];
|
||||
|
||||
const tokens: Token[] = [];
|
||||
|
||||
// 由于 NORMALIZE 模式下空白都折叠为单空格,这里按空格扫描即可。
|
||||
// 为了稳健性,也允许出现意外多空格(会跳过空段)。
|
||||
let i = 0;
|
||||
const n = s.length;
|
||||
|
||||
while (i < n) {
|
||||
// 跳过空格(或其他空白字符)
|
||||
while (i < n && /\s/.test(s[i]!)) i++;
|
||||
if (i >= n) break;
|
||||
|
||||
const start = i;
|
||||
while (i < n && !/\s/.test(s[i]!)) i++;
|
||||
const end = i;
|
||||
|
||||
tokens.push({ text: s.slice(start, end), start, end });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
40
client/src/features/textWrap/core/types.ts
Normal file
40
client/src/features/textWrap/core/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Text Wrap - core-contract
|
||||
*
|
||||
* 本文件定义“跨端一致的基础口径”所需的最小类型集合。
|
||||
* 注意:这里的索引(start/end)默认以 normalizedText(归一化后的文本)为基准。
|
||||
*/
|
||||
|
||||
export type Lang = 'TC' | 'EN';
|
||||
|
||||
export type WhitespacePolicy = 'NORMALIZE' | 'PRESERVE';
|
||||
|
||||
export type Token = {
|
||||
/** token 的原始文本(EN:词;TC:字符簇) */
|
||||
text: string;
|
||||
/** token 在 normalizedText 中的起始索引(包含) */
|
||||
start: number;
|
||||
/** token 在 normalizedText 中的结束索引(不包含) */
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type NormalizeWhitespaceResult = {
|
||||
/**
|
||||
* 归一化后的文本。
|
||||
* - NORMALIZE:折叠连续空白为单空格,并去首尾空白
|
||||
* - PRESERVE:仅去首尾空白(内部空白保持原样)
|
||||
*/
|
||||
normalizedText: string;
|
||||
/** 是否发生过“空白折叠/trim/非单空格分隔”等(用于后续 meta 打点) */
|
||||
hadMultiWhitespace: boolean;
|
||||
};
|
||||
|
||||
export type CoreConfig = {
|
||||
whitespacePolicy: WhitespacePolicy;
|
||||
/**
|
||||
* EN 关键词命中:两端可剥离的常见标点集合(必须全端一致)。
|
||||
* 来源建议与默认值参考:`设计说明文档/文档换行算法.md`(v1.2.1)2.3.1A-1
|
||||
*/
|
||||
punctuationStripSetEN: string[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user