222 lines
7.5 KiB
TypeScript
222 lines
7.5 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 { 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)) };
|
||
}
|
||
|