更新换行算法和APP-PUSH
This commit is contained in:
263
client/src/features/textWrap/searchWidget/beam.ts
Normal file
263
client/src/features/textWrap/searchWidget/beam.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
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 { approxWidth, DEFAULT_TOO_LONG_THRESHOLDS, isLineStartPunctTC, isTooLong } from './constraints';
|
||||
import { insertBeamTopK } from './topK';
|
||||
import type { BestWidgetLayout, SearchWidgetConfig, SearchWidgetInput, SearchWidgetResult, WidgetBeam } from './types';
|
||||
|
||||
const DEFAULT_CONFIG: SearchWidgetConfig = {
|
||||
beamK: 5,
|
||||
expandM: 12,
|
||||
tooLongThresholds: DEFAULT_TOO_LONG_THRESHOLDS,
|
||||
};
|
||||
|
||||
function mergeConfig(overrides?: Partial<SearchWidgetConfig> | null): SearchWidgetConfig {
|
||||
if (!overrides) return { ...DEFAULT_CONFIG };
|
||||
return {
|
||||
beamK: overrides.beamK ?? DEFAULT_CONFIG.beamK,
|
||||
expandM: overrides.expandM ?? DEFAULT_CONFIG.expandM,
|
||||
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 mergeApproxReason(a?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED', b?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED') {
|
||||
if (a === 'MEASURE_FAILED' || b === 'MEASURE_FAILED') return 'MEASURE_FAILED';
|
||||
if (a === 'WIDTH_UNKNOWN' || b === 'WIDTH_UNKNOWN') return 'WIDTH_UNKNOWN';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildBestLayout(best: WidgetBeam, debug?: boolean): BestWidgetLayout {
|
||||
const lines = best.lines.map((l) => l.text);
|
||||
const wrappedText = lines.join('\n');
|
||||
|
||||
const reason = best.lines.reduce<'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | undefined>((acc, l) => {
|
||||
return mergeApproxReason(acc, l.approxReason);
|
||||
}, undefined);
|
||||
|
||||
const meta = debug
|
||||
? { breaks: best.breaks, scoreTopTerms: best.scoreTopTerms, score: best.score, reason }
|
||||
: { breaks: best.breaks, reason };
|
||||
|
||||
return { breaks: best.breaks, lines, wrappedText, meta };
|
||||
}
|
||||
|
||||
export async function searchBestLayoutWidget(input: SearchWidgetInput): Promise<SearchWidgetResult> {
|
||||
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);
|
||||
|
||||
const tcPunctuations = input.scoring.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、'];
|
||||
|
||||
// 初始 beams
|
||||
let beams: WidgetBeam[] = [
|
||||
{ pos: 0, breaks: [], lines: [], score: 0, tieKey: [0, 0, 0, 0, 0] },
|
||||
];
|
||||
|
||||
for (let lineIndex = 1; lineIndex <= maxLines; lineIndex++) {
|
||||
let newBeams: WidgetBeam[] = [];
|
||||
|
||||
// 关键:保留已完成(pos==N)的 beams,避免“提前完成的解”在后续轮次被丢弃
|
||||
// 否则会错误地倾向“凑满 maxLines 行”的解,造成断句很怪(例如把“村莊”拆开)
|
||||
for (const b of beams) {
|
||||
if (b.pos === n) {
|
||||
newBeams = insertBeamTopK(newBeams, b, cfg.beamK);
|
||||
}
|
||||
}
|
||||
|
||||
// beams 顺序固定:按当前排序后的顺序扩展
|
||||
//(insertBeamTopK 会保持排序;这里再 sort 一次防御)
|
||||
beams = beams.slice().sort((a, b) => {
|
||||
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;
|
||||
});
|
||||
|
||||
for (const beam of beams) {
|
||||
const pos = beam.pos;
|
||||
if (pos >= n) continue;
|
||||
|
||||
// nextPos:pos 升序 + N
|
||||
const nextList: number[] = [];
|
||||
for (const p of positions) if (p > pos) nextList.push(p);
|
||||
if (n > pos) nextList.push(n);
|
||||
|
||||
// expandM 裁剪:只取前 M 个(确定性:按 pos 升序),但必须保证 N(结束边界)始终可选
|
||||
// 否则会出现“明明一行就能结束,却被裁剪掉只能继续拆分”的怪异换行。
|
||||
const M = Math.max(1, cfg.expandM | 0);
|
||||
let trimmed: number[];
|
||||
if (M === 1) {
|
||||
trimmed = [n];
|
||||
} else {
|
||||
const withoutN = nextList.filter((x) => x !== n);
|
||||
trimmed = withoutN.slice(0, M - 1);
|
||||
trimmed.push(n);
|
||||
}
|
||||
|
||||
for (const nextPos of trimmed) {
|
||||
if (nextPos <= pos) continue; // 禁止空行
|
||||
|
||||
// H6:TC 禁止行首标点(断点导致下一行第一个 token 为标点)
|
||||
if (lang === 'TC' && nextPos < n && isLineStartPunctTC(tokens, nextPos, tcPunctuations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokenCount = nextPos - pos;
|
||||
const charCount = lang === 'TC' ? tokenCount : 0;
|
||||
const lineText = buildLineText(tokens, pos, nextPos, rawSep);
|
||||
|
||||
let lineWidth: number;
|
||||
let isApprox = false;
|
||||
let approxReason: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | undefined;
|
||||
let widthTrusted = false;
|
||||
|
||||
if (input.measure.widthMode === 'APPROX') {
|
||||
isApprox = true;
|
||||
approxReason = 'WIDTH_UNKNOWN';
|
||||
lineWidth = approxWidth(tokenCount, charCount, lang);
|
||||
} else {
|
||||
const widgetEnableMeasure = input.measure.widgetEnableMeasure ?? true;
|
||||
const res = await measureSliceWidthCached({
|
||||
tokens,
|
||||
start: pos,
|
||||
end: nextPos,
|
||||
context: 'WIDGET',
|
||||
contextProfile: input.measure.contextProfile,
|
||||
fontSpec: input.measure.fontSpec,
|
||||
measureWidthImpl: input.measure.measureWidthImpl,
|
||||
widgetEnableMeasure,
|
||||
rawSeparators: rawSep,
|
||||
});
|
||||
|
||||
if (res.width === null) {
|
||||
isApprox = true;
|
||||
approxReason = res.meta.reason ?? 'WIDTH_UNKNOWN';
|
||||
lineWidth = approxWidth(tokenCount, charCount, lang);
|
||||
} else {
|
||||
widthTrusted = true;
|
||||
lineWidth = res.width;
|
||||
}
|
||||
}
|
||||
|
||||
// 超宽过滤:仅当宽度可信时执行
|
||||
if (widthTrusted && lineWidth > availableWidth) continue;
|
||||
|
||||
const newLine = {
|
||||
start: pos,
|
||||
end: nextPos,
|
||||
text: lineText,
|
||||
width: lineWidth,
|
||||
tokenCount,
|
||||
charCount,
|
||||
isApprox,
|
||||
approxReason,
|
||||
};
|
||||
|
||||
const newLines = beam.lines.concat([newLine]);
|
||||
const newBreaks = nextPos === n ? beam.breaks : beam.breaks.concat([nextPos]);
|
||||
const layoutCandidate = { breaks: newBreaks, lines: newLines.map(({ isApprox: _a, approxReason: _b, ...rest }) => rest) };
|
||||
|
||||
const scored = scoreLayout({
|
||||
tokens,
|
||||
layoutCandidate: layoutCandidate as any,
|
||||
lang,
|
||||
context: 'WIDGET',
|
||||
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: 'WIDGET',
|
||||
tokenCount: n,
|
||||
availableWidth,
|
||||
idealWidthRatio: input.scoring.config.idealWidthRatio,
|
||||
}) as number[];
|
||||
|
||||
const cand: WidgetBeam = {
|
||||
pos: nextPos,
|
||||
breaks: newBreaks,
|
||||
lines: newLines,
|
||||
score: scored.score,
|
||||
tieKey,
|
||||
scoreTopTerms: scored.scoreBreakdown?.terms,
|
||||
};
|
||||
|
||||
newBeams = insertBeamTopK(newBeams, cand, cfg.beamK);
|
||||
}
|
||||
}
|
||||
|
||||
beams = newBeams;
|
||||
if (beams.length === 0) break;
|
||||
}
|
||||
|
||||
const finished = beams.filter((b) => b.pos === n);
|
||||
if (finished.length === 0) {
|
||||
return { ok: false, reason: 'NO_CANDIDATE', meta: { tokenCount: n } };
|
||||
}
|
||||
|
||||
finished.sort((a, b) => {
|
||||
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 = finished[0]!;
|
||||
return { ok: true, bestLayout: buildBestLayout(best, Boolean(input.scoring.debug)) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user