更新换行算法和APP-PUSH
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MeasureWidthImpl } from '../../measure/types';
|
||||
import { searchBestLayoutApp } from '../index';
|
||||
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from '../../scoring/index';
|
||||
|
||||
function t(text: string, start: number) {
|
||||
return { text, start, end: start + text.length };
|
||||
}
|
||||
|
||||
describe('textWrap search-engine-app', () => {
|
||||
it('确定性:同输入多次调用 breaks/lines 必须一致', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
const input = {
|
||||
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
|
||||
lang: 'EN' as const,
|
||||
breakpoints: [
|
||||
{ pos: 1, kind: 'SPACE', priority: 10 },
|
||||
{ pos: 2, kind: 'SPACE', priority: 10 },
|
||||
{ pos: 3, kind: 'SPACE', priority: 10 },
|
||||
],
|
||||
availableWidth: 6,
|
||||
maxLines: 2,
|
||||
lineMode: 'AUTO' as const,
|
||||
measure: {
|
||||
contextProfile: 'APP|ios|test',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
},
|
||||
scoring: {
|
||||
config: {
|
||||
weights: DEFAULT_WEIGHTS,
|
||||
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||
ellipsisToken: '…',
|
||||
tcParticleWhitelist: [],
|
||||
},
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
debug: true,
|
||||
},
|
||||
};
|
||||
|
||||
const a = await searchBestLayoutApp(input as any);
|
||||
const b = await searchBestLayoutApp(input as any);
|
||||
const c = await searchBestLayoutApp(input as any);
|
||||
|
||||
expect(a).toEqual(b);
|
||||
expect(b).toEqual(c);
|
||||
expect(impl).toHaveBeenCalled(); // 有测量发生
|
||||
});
|
||||
|
||||
it('lineMode=FIXED:无解必须返回 NO_CANDIDATE', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
const res = await searchBestLayoutApp({
|
||||
tokens: [t('I', 0), t('am', 2)],
|
||||
lang: 'EN',
|
||||
breakpoints: [{ pos: 1, kind: 'SPACE', priority: 10 }],
|
||||
availableWidth: 100,
|
||||
maxLines: 3,
|
||||
lineMode: 'FIXED',
|
||||
measure: {
|
||||
contextProfile: 'APP|ios|fixed',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
},
|
||||
scoring: {
|
||||
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
|
||||
});
|
||||
|
||||
it('TC H6:断点导致下一行行首为标点时必须禁止', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
const res = await searchBestLayoutApp({
|
||||
tokens: [t('我', 0), t('好', 1), t(',', 2), t('累', 3)],
|
||||
lang: 'TC',
|
||||
// 若选择 pos=2,则第二行行首 token 为 ','(应禁止)
|
||||
breakpoints: [{ pos: 2, kind: 'PUNCT', priority: 30 }],
|
||||
availableWidth: 100,
|
||||
maxLines: 2,
|
||||
lineMode: 'FIXED',
|
||||
measure: {
|
||||
contextProfile: 'APP|ios|tc',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
},
|
||||
scoring: {
|
||||
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], tcPunctuations: [','] },
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
},
|
||||
});
|
||||
|
||||
// lineMode=FIXED 且唯一断点被禁止,因此无解
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
|
||||
});
|
||||
|
||||
it('TOO_LONG:超过阈值直接返回 TOO_LONG', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
const tokens = Array.from({ length: 31 }).map((_, i) => t('a', i));
|
||||
const res = await searchBestLayoutApp({
|
||||
tokens,
|
||||
lang: 'EN',
|
||||
breakpoints: [],
|
||||
availableWidth: 100,
|
||||
maxLines: 2,
|
||||
lineMode: 'AUTO',
|
||||
measure: {
|
||||
contextProfile: 'APP|ios|toolong',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
},
|
||||
scoring: {
|
||||
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.reason).toBe('TOO_LONG');
|
||||
});
|
||||
});
|
||||
|
||||
21
client/src/features/textWrap/searchApp/constraints.ts
Normal file
21
client/src/features/textWrap/searchApp/constraints.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Token } from '../core/types';
|
||||
|
||||
export const DEFAULT_TOO_LONG_THRESHOLDS = Object.freeze({ EN: 30, TC: 60 });
|
||||
|
||||
export function isTooLong(tokens: Token[], lang: 'TC' | 'EN', thresholds: { EN: number; TC: number }): boolean {
|
||||
const n = Math.max(0, tokens.length | 0);
|
||||
const limit = lang === 'EN' ? thresholds.EN : thresholds.TC;
|
||||
return n > limit;
|
||||
}
|
||||
|
||||
export function isLineStartPunctTC(tokens: Token[], pos: number, tcPunctuations: string[]): boolean {
|
||||
const p = pos | 0;
|
||||
if (p <= 0) return false; // 第一行不受 H6 影响
|
||||
const t = tokens[p]?.text ?? '';
|
||||
return tcPunctuations.includes(t);
|
||||
}
|
||||
|
||||
export function isOverWidth(width: number, availableWidth: number): boolean {
|
||||
return width > availableWidth;
|
||||
}
|
||||
|
||||
221
client/src/features/textWrap/searchApp/dpTopK.ts
Normal file
221
client/src/features/textWrap/searchApp/dpTopK.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { Token } from '../core/types';
|
||||
import type { Breakpoint } from '../breakpoints/types';
|
||||
|
||||
import { joinTokens } from '../core/joinTokens';
|
||||
import { measureSliceWidthCached } from '../measure/measureSliceWidthCached';
|
||||
import { buildTieKey, scoreLayout } from '../scoring/index';
|
||||
|
||||
import { DEFAULT_TOO_LONG_THRESHOLDS, isLineStartPunctTC, isOverWidth, isTooLong } from './constraints';
|
||||
import { insertTopK } from './topK';
|
||||
import type { BestLayout, PartialLayout, SearchAppConfig, SearchAppInput, SearchAppResult } from './types';
|
||||
|
||||
const DEFAULT_CONFIG: SearchAppConfig = {
|
||||
topK: 10,
|
||||
tooLongThresholds: DEFAULT_TOO_LONG_THRESHOLDS,
|
||||
};
|
||||
|
||||
function mergeConfig(overrides?: Partial<SearchAppConfig> | null): SearchAppConfig {
|
||||
if (!overrides) return { ...DEFAULT_CONFIG };
|
||||
return {
|
||||
topK: overrides.topK ?? DEFAULT_CONFIG.topK,
|
||||
tooLongThresholds: overrides.tooLongThresholds ?? DEFAULT_CONFIG.tooLongThresholds,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueSortedPositions(bps: Breakpoint[], n: number): number[] {
|
||||
const set = new Set<number>();
|
||||
for (const b of bps) {
|
||||
const p = b.pos | 0;
|
||||
if (p >= 1 && p <= n - 1) set.add(p);
|
||||
}
|
||||
const arr = Array.from(set);
|
||||
arr.sort((a, b) => a - b);
|
||||
return arr;
|
||||
}
|
||||
|
||||
function rawSeparatorsForLang(tokensLen: number, lang: 'TC' | 'EN'): string[] | undefined {
|
||||
if (lang !== 'TC') return undefined;
|
||||
// TC:默认拼接不插入额外空格;空格作为 token 自身出现
|
||||
return Array.from({ length: Math.max(0, tokensLen) }, () => '');
|
||||
}
|
||||
|
||||
function buildLineText(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
|
||||
return joinTokens(tokens, start, end, rawSeparators);
|
||||
}
|
||||
|
||||
function buildBestLayout(best: PartialLayout, debug?: boolean): BestLayout {
|
||||
const lines = best.lines.map((l) => l.text);
|
||||
const wrappedText = lines.join('\n');
|
||||
const meta = debug
|
||||
? { breaks: best.breaks, scoreTopTerms: best.scoreTopTerms, score: best.score }
|
||||
: { breaks: best.breaks };
|
||||
return { breaks: best.breaks, lines, wrappedText, meta };
|
||||
}
|
||||
|
||||
export async function searchBestLayoutApp(input: SearchAppInput): Promise<SearchAppResult> {
|
||||
const cfg = mergeConfig(input.config ?? null);
|
||||
const tokens = input.tokens ?? [];
|
||||
const lang = input.lang;
|
||||
const n = tokens.length;
|
||||
|
||||
if (isTooLong(tokens, lang, cfg.tooLongThresholds)) {
|
||||
return { ok: false, reason: 'TOO_LONG', meta: { tokenCount: n } };
|
||||
}
|
||||
|
||||
const maxLines = Math.max(1, input.maxLines | 0);
|
||||
const availableWidth = input.availableWidth;
|
||||
|
||||
const positions = uniqueSortedPositions(input.breakpoints ?? [], n);
|
||||
const rawSep = rawSeparatorsForLang(tokens.length, lang);
|
||||
|
||||
// dp[pos][linesUsed] -> PartialLayout[]
|
||||
const dp: PartialLayout[][][] = Array.from({ length: n + 1 }, () =>
|
||||
Array.from({ length: maxLines + 1 }, () => [])
|
||||
);
|
||||
|
||||
dp[0][0] = [
|
||||
{
|
||||
pos: 0,
|
||||
breaks: [],
|
||||
lines: [],
|
||||
score: 0,
|
||||
tieKey: [0, 0, 0, 0, 0], // 空布局占位,后续会用 buildTieKey 覆盖
|
||||
},
|
||||
];
|
||||
|
||||
const tcPunctuations = input.scoring.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、'];
|
||||
|
||||
// DP 遍历顺序必须固定
|
||||
for (let pos = 0; pos <= n; pos++) {
|
||||
for (let linesUsed = 0; linesUsed <= maxLines - 1; linesUsed++) {
|
||||
const states = dp[pos][linesUsed];
|
||||
if (!states || states.length === 0) continue;
|
||||
|
||||
// 枚举 nextPos:breakpoints 中 >pos 的位置(升序)+ N
|
||||
const nextList: number[] = [];
|
||||
for (const p of positions) if (p > pos) nextList.push(p);
|
||||
if (n > pos) nextList.push(n);
|
||||
|
||||
for (const state of states) {
|
||||
for (const nextPos of nextList) {
|
||||
if (nextPos <= pos) continue; // 禁止空行
|
||||
|
||||
// H6:TC 禁止“行首标点”(断点导致下一行第一个 token 为标点)
|
||||
// 断点位置为 nextPos,因此要检查 tokens[nextPos]
|
||||
if (lang === 'TC' && nextPos < n && isLineStartPunctTC(tokens, nextPos, tcPunctuations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lineText = buildLineText(tokens, pos, nextPos, rawSep);
|
||||
|
||||
const widthRes = await measureSliceWidthCached({
|
||||
tokens,
|
||||
start: pos,
|
||||
end: nextPos,
|
||||
context: 'APP',
|
||||
contextProfile: input.measure.contextProfile,
|
||||
fontSpec: input.measure.fontSpec,
|
||||
measureWidthImpl: input.measure.measureWidthImpl,
|
||||
rawSeparators: rawSep,
|
||||
});
|
||||
|
||||
if (widthRes.width === null) {
|
||||
// App 搜索必须依赖宽度派;宽度不可用交给 overflow-fallback 统一处理
|
||||
return {
|
||||
ok: false,
|
||||
reason: widthRes.meta.reason === 'MEASURE_FAILED' ? 'MEASURE_FAILED' : 'WIDTH_UNKNOWN',
|
||||
meta: { tokenCount: n },
|
||||
};
|
||||
}
|
||||
|
||||
const lineWidth = widthRes.width;
|
||||
if (isOverWidth(lineWidth, availableWidth)) continue; // H1
|
||||
|
||||
const tokenCount = nextPos - pos;
|
||||
const charCount = lang === 'TC' ? tokenCount : 0;
|
||||
|
||||
const newLine = {
|
||||
start: pos,
|
||||
end: nextPos,
|
||||
text: lineText,
|
||||
width: lineWidth,
|
||||
tokenCount,
|
||||
charCount,
|
||||
};
|
||||
|
||||
const newLines = state.lines.concat([newLine]);
|
||||
const newBreaks = nextPos === n ? state.breaks : state.breaks.concat([nextPos]);
|
||||
|
||||
// 构造 layoutCandidate 并评分(为保证确定性,首版直接全量评分)
|
||||
const layoutCandidate = { breaks: newBreaks, lines: newLines };
|
||||
|
||||
const scored = scoreLayout({
|
||||
tokens,
|
||||
layoutCandidate: layoutCandidate as any,
|
||||
lang,
|
||||
context: 'APP',
|
||||
availableWidth,
|
||||
config: input.scoring.config,
|
||||
lexicons: input.scoring.lexicons,
|
||||
debug: Boolean(input.scoring.debug),
|
||||
});
|
||||
|
||||
const tieKey = buildTieKey({
|
||||
scoredLayout: scored as any,
|
||||
layoutCandidate: layoutCandidate as any,
|
||||
lang,
|
||||
context: 'APP',
|
||||
tokenCount: n,
|
||||
availableWidth,
|
||||
idealWidthRatio: input.scoring.config.idealWidthRatio,
|
||||
}) as number[];
|
||||
|
||||
const cand: PartialLayout = {
|
||||
pos: nextPos,
|
||||
breaks: newBreaks,
|
||||
lines: newLines,
|
||||
score: scored.score,
|
||||
tieKey,
|
||||
scoreTopTerms: scored.scoreBreakdown?.terms,
|
||||
};
|
||||
|
||||
dp[nextPos][linesUsed + 1] = insertTopK(dp[nextPos][linesUsed + 1], cand, cfg.topK);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 结束选择
|
||||
const collect: PartialLayout[] = [];
|
||||
if (input.lineMode === 'FIXED') {
|
||||
collect.push(...dp[n][maxLines]);
|
||||
} else {
|
||||
for (let linesUsed = 1; linesUsed <= maxLines; linesUsed++) {
|
||||
collect.push(...dp[n][linesUsed]);
|
||||
}
|
||||
}
|
||||
|
||||
if (collect.length === 0) {
|
||||
return { ok: false, reason: 'NO_CANDIDATE', meta: { tokenCount: n } };
|
||||
}
|
||||
|
||||
// TopK 容器内本身已排序,但跨不同 linesUsed 需要再全局选最优
|
||||
collect.sort((a, b) => {
|
||||
// 复用 insertTopK 的 compare 规则(在 topK.ts 内部)会更好,但这里直接再排序一次确保稳定
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
const na = a.tieKey;
|
||||
const nb = b.tieKey;
|
||||
const m = Math.min(na.length, nb.length);
|
||||
for (let i = 0; i < m; i++) {
|
||||
const av = na[i] ?? 0;
|
||||
const bv = nb[i] ?? 0;
|
||||
if (av < bv) return -1;
|
||||
if (av > bv) return 1;
|
||||
}
|
||||
return na.length - nb.length;
|
||||
});
|
||||
|
||||
const best = collect[0]!;
|
||||
return { ok: true, bestLayout: buildBestLayout(best, Boolean(input.scoring.debug)) };
|
||||
}
|
||||
|
||||
4
client/src/features/textWrap/searchApp/index.ts
Normal file
4
client/src/features/textWrap/searchApp/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type { BestLayout, LineMode, SearchAppConfig, SearchAppInput, SearchAppResult, SearchFailureReason } from './types';
|
||||
|
||||
export { searchBestLayoutApp } from './dpTopK';
|
||||
|
||||
62
client/src/features/textWrap/searchApp/topK.ts
Normal file
62
client/src/features/textWrap/searchApp/topK.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { PartialLayout } from './types';
|
||||
import { compareBreaksLexicographically } from '../core/index';
|
||||
|
||||
function compareTieKey(a: number[], b: number[]): number {
|
||||
const n = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const av = a[i] ?? 0;
|
||||
const bv = b[i] ?? 0;
|
||||
if (av < bv) return -1;
|
||||
if (av > bv) return 1;
|
||||
}
|
||||
if (a.length < b.length) return -1;
|
||||
if (a.length > b.length) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* TopK 排序(确定性):
|
||||
* - score 越大越优
|
||||
* - tieKey 越小越优(按 11 节构造的数值 key)
|
||||
* - breaks 字典序更小者更优(11.0A)
|
||||
*/
|
||||
export function compareLayouts(a: PartialLayout, b: PartialLayout): number {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
const t = compareTieKey(a.tieKey, b.tieKey);
|
||||
if (t !== 0) return t;
|
||||
return compareBreaksLexicographically(a.breaks, b.breaks);
|
||||
}
|
||||
|
||||
function breaksKey(breaks: number[]): string {
|
||||
// breaks 序列作为去重 key(确定性)
|
||||
return breaks.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入 TopK(去重 + 排序 + 截断)。
|
||||
*
|
||||
* 去重规则:
|
||||
* - 同 breaks 仅保留最优(score 更高;若相同按 tieKey/breaks 比较)
|
||||
*/
|
||||
export function insertTopK(list: PartialLayout[], cand: PartialLayout, k: number): PartialLayout[] {
|
||||
const K = Math.max(1, k | 0);
|
||||
const out = list.slice();
|
||||
|
||||
const key = breaksKey(cand.breaks);
|
||||
const idx = out.findIndex((x) => breaksKey(x.breaks) === key);
|
||||
if (idx >= 0) {
|
||||
const existing = out[idx]!;
|
||||
if (compareLayouts(cand, existing) < 0) {
|
||||
// cand 更差,忽略
|
||||
return out;
|
||||
}
|
||||
out[idx] = cand;
|
||||
} else {
|
||||
out.push(cand);
|
||||
}
|
||||
|
||||
out.sort(compareLayouts);
|
||||
if (out.length > K) out.length = K;
|
||||
return out;
|
||||
}
|
||||
|
||||
71
client/src/features/textWrap/searchApp/types.ts
Normal file
71
client/src/features/textWrap/searchApp/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Token } from '../core/types';
|
||||
import type { Breakpoint } from '../breakpoints/types';
|
||||
import type { ContextProfile, FontSpec, MeasureWidthImpl } from '../measure/types';
|
||||
import type { Lexicons, ScoringConfig, ScoreTerm } from '../scoring/types';
|
||||
|
||||
export type LineMode = 'AUTO' | 'FIXED';
|
||||
|
||||
export type SearchAppConfig = {
|
||||
/** TopK(文档建议 App=10) */
|
||||
topK: number;
|
||||
/** 超长阈值(用于 TOO_LONG) */
|
||||
tooLongThresholds: { EN: number; TC: number };
|
||||
};
|
||||
|
||||
export type SearchMeasureInput = {
|
||||
contextProfile: ContextProfile;
|
||||
fontSpec: Partial<FontSpec> | null | undefined;
|
||||
measureWidthImpl?: MeasureWidthImpl;
|
||||
};
|
||||
|
||||
export type SearchScoringInput = {
|
||||
config: ScoringConfig;
|
||||
lexicons: Lexicons;
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
export type SearchAppInput = {
|
||||
tokens: Token[];
|
||||
lang: 'TC' | 'EN';
|
||||
breakpoints: Breakpoint[];
|
||||
availableWidth: number;
|
||||
maxLines: number;
|
||||
lineMode: LineMode;
|
||||
measure: SearchMeasureInput;
|
||||
scoring: SearchScoringInput;
|
||||
config?: Partial<SearchAppConfig>;
|
||||
};
|
||||
|
||||
export type BestLayout = {
|
||||
breaks: number[];
|
||||
lines: string[];
|
||||
wrappedText: string;
|
||||
meta?: {
|
||||
breaks: number[];
|
||||
scoreTopTerms?: ScoreTerm[];
|
||||
score?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SearchFailureReason = 'TOO_LONG' | 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | 'NO_CANDIDATE';
|
||||
|
||||
export type SearchAppResult =
|
||||
| { ok: true; bestLayout: BestLayout }
|
||||
| { ok: false; reason: SearchFailureReason; meta?: { tokenCount: number } };
|
||||
|
||||
export type PartialLayout = {
|
||||
pos: number;
|
||||
breaks: number[];
|
||||
lines: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
width: number;
|
||||
tokenCount: number;
|
||||
charCount: number;
|
||||
}>;
|
||||
score: number;
|
||||
tieKey: number[];
|
||||
scoreTopTerms?: ScoreTerm[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user