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 | 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(); 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 { 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)) }; }