264 lines
8.9 KiB
TypeScript
264 lines
8.9 KiB
TypeScript
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)) };
|
||
}
|
||
|