Files
mindfulness/client/src/features/textWrap/searchWidget/beam.ts
2026-02-10 11:39:33 +08:00

264 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
// nextPospos 升序 + 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; // 禁止空行
// H6TC 禁止行首标点(断点导致下一行第一个 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)) };
}