Merge pull request '更新换行算法和APP-PUSH' (#19) from damer into main
Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
当前有一个很大的 spec.md(大需求规范),需要按业务逻辑拆分成多个子模块规范。
|
||||
当前有一个很大的 spec.md(大需求规范),需要按业务逻辑合理拆分成多个子模块规范。
|
||||
|
||||
请按以下规则拆分:
|
||||
|
||||
|
||||
@@ -11,11 +11,14 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
const projectId =
|
||||
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
|
||||
// 兼容部分 CI/EAS 注入的变量名
|
||||
process.env.EAS_PROJECT_ID ||
|
||||
undefined;
|
||||
process.env.EAS_PROJECT_ID;
|
||||
|
||||
return {
|
||||
...config,
|
||||
// ExpoConfig 的类型要求 name 必填,避免 `...config` 的可选类型导致 tsc 报错
|
||||
name: config.name ?? 'client',
|
||||
// slug 在绝大多数场景也建议固定为非空字符串(保持与 app.json 一致)
|
||||
slug: config.slug ?? 'client',
|
||||
extra: {
|
||||
...(config.extra ?? {}),
|
||||
eas: {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/images/splashScreen.png",
|
||||
"image": "./assets/images/Screen_page.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#EAD2BA"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Dimensions,
|
||||
Text,
|
||||
Pressable,
|
||||
PanResponder,
|
||||
Animated as RNAnimated,
|
||||
ImageBackground,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
@@ -43,6 +53,8 @@ import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
||||
|
||||
import { getBootId } from '@/src/utils/bootSession';
|
||||
import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme';
|
||||
import { wrapText } from '@/src/features/textWrap';
|
||||
import { defaultMeasureWidthImpl } from '@/src/features/textWrap/measure';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
@@ -97,6 +109,9 @@ export default function HomeScreen() {
|
||||
const [likeFilled, setLikeFilled] = useState(false);
|
||||
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
||||
const [wrappedText, setWrappedText] = useState<string>('');
|
||||
const wrapLogRef = useRef<{ key: string } | null>(null);
|
||||
|
||||
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
||||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||
@@ -176,6 +191,120 @@ export default function HomeScreen() {
|
||||
};
|
||||
}, [currentFeed, index]);
|
||||
|
||||
// Home 文案:使用自主换行算法(Text Wrap 模块)
|
||||
// - 通过 onLayout 获取容器宽度
|
||||
// - 注入真实测量实现,确保“宽度派”评分与实际渲染一致
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// 未拿到宽度前先用原文(避免闪烁)
|
||||
if (!cardWidth || cardWidth <= 0) {
|
||||
if (__DEV__) {
|
||||
const key = `noWidth|${item.id}|${String(cardWidth)}`;
|
||||
if (wrapLogRef.current?.key !== key) {
|
||||
wrapLogRef.current = { key };
|
||||
console.log('[TextWrap][Home] cardWidth 未就绪,先回退原文', {
|
||||
itemId: item.id,
|
||||
lang: recoLang,
|
||||
cardWidth,
|
||||
themeMode,
|
||||
});
|
||||
}
|
||||
}
|
||||
setWrappedText(item.text);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const paddingHorizontal = themeMode === 'scenery' ? 50 : 30;
|
||||
const availableWidth = Math.max(0, Math.floor(cardWidth - paddingHorizontal * 2));
|
||||
|
||||
const lang = recoLang === 'en' ? 'EN' : 'TC';
|
||||
|
||||
const fontFamily =
|
||||
lang === 'EN'
|
||||
? 'STIXTwoText'
|
||||
: Platform.select({
|
||||
ios: 'System',
|
||||
android: 'sans-serif',
|
||||
default: 'System',
|
||||
});
|
||||
|
||||
const fontSpec = {
|
||||
fontSize: 22,
|
||||
fontWeight: lang === 'EN' ? '600' : '700',
|
||||
fontFamily: String(fontFamily ?? 'System'),
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (__DEV__) {
|
||||
const key = `start|${item.id}|${lang}|${availableWidth}|${themeMode}`;
|
||||
if (wrapLogRef.current?.key !== key) {
|
||||
wrapLogRef.current = { key };
|
||||
console.log('[TextWrap][Home] wrapText 开始', {
|
||||
itemId: item.id,
|
||||
lang,
|
||||
themeMode,
|
||||
cardWidth,
|
||||
paddingHorizontal,
|
||||
availableWidth,
|
||||
fontSpec,
|
||||
textPreview: String(item.text ?? '').slice(0, 80),
|
||||
textLength: String(item.text ?? '').length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const res = await wrapText({
|
||||
text: item.text,
|
||||
lang,
|
||||
context: 'APP',
|
||||
availableWidth,
|
||||
maxLines: 3,
|
||||
overflowMode: 'CLIP',
|
||||
lineMode: 'AUTO',
|
||||
configVersion: 'v1',
|
||||
debug: __DEV__,
|
||||
fontSpec,
|
||||
contextProfile: `APP|${Platform.OS}|home|${lang}`,
|
||||
measureWidthImpl: defaultMeasureWidthImpl,
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
if (__DEV__) {
|
||||
console.log('[TextWrap][Home] wrapText 成功', {
|
||||
itemId: item.id,
|
||||
wrappedText: res.wrappedText,
|
||||
linesCount: res.lines.length,
|
||||
meta: res.meta,
|
||||
});
|
||||
}
|
||||
setWrappedText(res.wrappedText);
|
||||
} catch (error) {
|
||||
// 任何异常都回退到原文,避免影响 Home 主流程
|
||||
if (__DEV__) {
|
||||
console.log('[TextWrap][Home] wrapText 异常,回退原文', {
|
||||
itemId: item.id,
|
||||
lang,
|
||||
availableWidth,
|
||||
fontSpec,
|
||||
errorName: (error as any)?.name,
|
||||
errorMessage: String((error as any)?.message ?? error),
|
||||
errorStack: (error as any)?.stack,
|
||||
});
|
||||
}
|
||||
if (cancelled) return;
|
||||
setWrappedText(item.text);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [item.text, cardWidth, recoLang, themeMode]);
|
||||
|
||||
// 异步拉取新文案
|
||||
const fetchNewFeed = useCallback(async () => {
|
||||
if (isFetchingRef.current) return;
|
||||
@@ -430,9 +559,15 @@ export default function HomeScreen() {
|
||||
</CircleIconButton>
|
||||
</View>
|
||||
|
||||
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
||||
<Animated.View
|
||||
style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}
|
||||
onLayout={(e) => {
|
||||
const w = e.nativeEvent.layout.width;
|
||||
if (Number.isFinite(w) && w > 0) setCardWidth(w);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||||
{item.text}
|
||||
{wrappedText || item.text}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
|
||||
BIN
client/assets/images/Screen_page.png
Normal file
BIN
client/assets/images/Screen_page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
@@ -2240,304 +2240,304 @@ PODS:
|
||||
- Yoga (0.0.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)"
|
||||
- "EXConstants (from `../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios`)"
|
||||
- "EXJSONUtils (from `../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios`)"
|
||||
- "EXManifests (from `../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios`)"
|
||||
- "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios`)"
|
||||
- "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo`)"
|
||||
- "expo-dev-client (from `../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios`)"
|
||||
- "expo-dev-launcher (from `../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher`)"
|
||||
- "expo-dev-menu (from `../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu`)"
|
||||
- "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)"
|
||||
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)"
|
||||
- "ExpoCrypto (from `../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios`)"
|
||||
- "ExpoDevice (from `../node_modules/.pnpm/expo-device@8.0.10_expo@54.0.32/node_modules/expo-device/ios`)"
|
||||
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)"
|
||||
- "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)"
|
||||
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios`)"
|
||||
- "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios`)"
|
||||
- "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios`)"
|
||||
- "ExpoLinking (from `../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)"
|
||||
- "ExpoLocalization (from `../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios`)"
|
||||
- "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)"
|
||||
- "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)"
|
||||
- "ExpoWebBrowser (from `../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios`)"
|
||||
- "EXUpdatesInterface (from `../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios`)"
|
||||
- "FBLazyVector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector`)"
|
||||
- "hermes-engine (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)"
|
||||
- "RCTDeprecation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)"
|
||||
- "RCTRequired (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Required`)"
|
||||
- "RCTTypeSafety (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/TypeSafety`)"
|
||||
- "React (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)"
|
||||
- "React-callinvoker (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/callinvoker`)"
|
||||
- "React-Core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)"
|
||||
- "React-Core-prebuilt (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React-Core-prebuilt.podspec`)"
|
||||
- "React-Core/RCTWebSocket (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)"
|
||||
- "React-CoreModules (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/CoreModules`)"
|
||||
- "React-cxxreact (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/cxxreact`)"
|
||||
- "React-debug (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/debug`)"
|
||||
- "React-defaultsnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/defaults`)"
|
||||
- "React-domnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/dom`)"
|
||||
- "React-Fabric (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||
- "React-FabricComponents (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||
- "React-FabricImage (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||
- "React-featureflags (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/featureflags`)"
|
||||
- "React-featureflagsnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)"
|
||||
- "React-graphics (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/graphics`)"
|
||||
- "React-hermes (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes`)"
|
||||
- "React-idlecallbacksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)"
|
||||
- "React-ImageManager (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)"
|
||||
- "React-jserrorhandler (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jserrorhandler`)"
|
||||
- "React-jsi (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsi`)"
|
||||
- "React-jsiexecutor (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsiexecutor`)"
|
||||
- "React-jsinspector (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern`)"
|
||||
- "React-jsinspectorcdp (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)"
|
||||
- "React-jsinspectornetwork (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/network`)"
|
||||
- "React-jsinspectortracing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)"
|
||||
- "React-jsitooling (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsitooling`)"
|
||||
- "React-jsitracing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes/executor/`)"
|
||||
- "React-logger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger`)"
|
||||
- "React-Mapbuffer (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||
- "React-microtasksnativemodule (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)"
|
||||
- "react-native-safe-area-context (from `../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context`)"
|
||||
- "React-NativeModulesApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)"
|
||||
- "React-oscompat (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat`)"
|
||||
- "React-perflogger (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger`)"
|
||||
- "React-performancetimeline (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/performance/timeline`)"
|
||||
- "React-RCTActionSheet (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/ActionSheetIOS`)"
|
||||
- "React-RCTAnimation (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/NativeAnimation`)"
|
||||
- "React-RCTAppDelegate (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/AppDelegate`)"
|
||||
- "React-RCTBlob (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Blob`)"
|
||||
- "React-RCTFabric (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React`)"
|
||||
- "React-RCTFBReactNativeSpec (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React`)"
|
||||
- "React-RCTImage (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Image`)"
|
||||
- "React-RCTLinking (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/LinkingIOS`)"
|
||||
- "React-RCTNetwork (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Network`)"
|
||||
- "React-RCTRuntime (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/Runtime`)"
|
||||
- "React-RCTSettings (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Settings`)"
|
||||
- "React-RCTText (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Text`)"
|
||||
- "React-RCTVibration (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Vibration`)"
|
||||
- "React-rendererconsistency (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/consistency`)"
|
||||
- "React-renderercss (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/css`)"
|
||||
- "React-rendererdebug (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/debug`)"
|
||||
- "React-RuntimeApple (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime/platform/ios`)"
|
||||
- "React-RuntimeCore (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime`)"
|
||||
- "React-runtimeexecutor (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/runtimeexecutor`)"
|
||||
- "React-RuntimeHermes (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime`)"
|
||||
- "React-runtimescheduler (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)"
|
||||
- "React-timing (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/timing`)"
|
||||
- "React-utils (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/utils`)"
|
||||
- EXApplication (from `../node_modules/expo-application/ios`)
|
||||
- EXConstants (from `../node_modules/expo-constants/ios`)
|
||||
- EXJSONUtils (from `../node_modules/expo-json-utils/ios`)
|
||||
- EXManifests (from `../node_modules/expo-manifests/ios`)
|
||||
- EXNotifications (from `../node_modules/expo-notifications/ios`)
|
||||
- Expo (from `../node_modules/expo`)
|
||||
- expo-dev-client (from `../node_modules/expo-dev-client/ios`)
|
||||
- expo-dev-launcher (from `../node_modules/expo-dev-launcher`)
|
||||
- expo-dev-menu (from `../node_modules/expo-dev-menu`)
|
||||
- expo-dev-menu-interface (from `../node_modules/expo-dev-menu-interface/ios`)
|
||||
- ExpoAsset (from `../node_modules/expo-asset/ios`)
|
||||
- ExpoCrypto (from `../node_modules/expo-crypto/ios`)
|
||||
- ExpoDevice (from `../node_modules/expo-device/ios`)
|
||||
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
||||
- ExpoFont (from `../node_modules/expo-font/ios`)
|
||||
- ExpoHead (from `../node_modules/expo-router/ios`)
|
||||
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
|
||||
- ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
|
||||
- ExpoLinking (from `../node_modules/expo-linking/ios`)
|
||||
- ExpoLocalization (from `../node_modules/expo-localization/ios`)
|
||||
- ExpoModulesCore (from `../node_modules/expo-modules-core`)
|
||||
- ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`)
|
||||
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
|
||||
- EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`)
|
||||
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
|
||||
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
||||
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
|
||||
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
|
||||
- RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
|
||||
- React (from `../node_modules/react-native/`)
|
||||
- React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`)
|
||||
- React-Core (from `../node_modules/react-native/`)
|
||||
- React-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`)
|
||||
- React-Core/RCTWebSocket (from `../node_modules/react-native/`)
|
||||
- React-CoreModules (from `../node_modules/react-native/React/CoreModules`)
|
||||
- React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`)
|
||||
- React-debug (from `../node_modules/react-native/ReactCommon/react/debug`)
|
||||
- React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`)
|
||||
- React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`)
|
||||
- React-Fabric (from `../node_modules/react-native/ReactCommon`)
|
||||
- React-FabricComponents (from `../node_modules/react-native/ReactCommon`)
|
||||
- React-FabricImage (from `../node_modules/react-native/ReactCommon`)
|
||||
- React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`)
|
||||
- React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)
|
||||
- React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`)
|
||||
- React-hermes (from `../node_modules/react-native/ReactCommon/hermes`)
|
||||
- React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)
|
||||
- React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)
|
||||
- React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`)
|
||||
- React-jsi (from `../node_modules/react-native/ReactCommon/jsi`)
|
||||
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
|
||||
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`)
|
||||
- React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)
|
||||
- React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`)
|
||||
- React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)
|
||||
- React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`)
|
||||
- React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`)
|
||||
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
|
||||
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
|
||||
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
||||
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
||||
- React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
|
||||
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
|
||||
- React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`)
|
||||
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
|
||||
- React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
|
||||
- React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`)
|
||||
- React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`)
|
||||
- React-RCTFabric (from `../node_modules/react-native/React`)
|
||||
- React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`)
|
||||
- React-RCTImage (from `../node_modules/react-native/Libraries/Image`)
|
||||
- React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`)
|
||||
- React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`)
|
||||
- React-RCTRuntime (from `../node_modules/react-native/React/Runtime`)
|
||||
- React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`)
|
||||
- React-RCTText (from `../node_modules/react-native/Libraries/Text`)
|
||||
- React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`)
|
||||
- React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`)
|
||||
- React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`)
|
||||
- React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`)
|
||||
- React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`)
|
||||
- React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`)
|
||||
- React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`)
|
||||
- React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`)
|
||||
- React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
|
||||
- React-timing (from `../node_modules/react-native/ReactCommon/react/timing`)
|
||||
- React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
|
||||
- ReactAppDependencyProvider (from `build/generated/ios`)
|
||||
- ReactCodegen (from `build/generated/ios`)
|
||||
- "ReactCommon/turbomodule/core (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)"
|
||||
- "ReactNativeDependencies (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)"
|
||||
- "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage`)"
|
||||
- "RNGestureHandler (from `../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1._tylda4qoo2jtxaj3472gn4luma/node_modules/react-native-gesture-handler`)"
|
||||
- "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated`)"
|
||||
- "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)"
|
||||
- "RNSVG (from `../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)"
|
||||
- "RNWorklets (from `../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets`)"
|
||||
- "Yoga (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga`)"
|
||||
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
|
||||
- ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
|
||||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
|
||||
- RNReanimated (from `../node_modules/react-native-reanimated`)
|
||||
- RNScreens (from `../node_modules/react-native-screens`)
|
||||
- RNSVG (from `../node_modules/react-native-svg`)
|
||||
- RNWorklets (from `../node_modules/react-native-worklets`)
|
||||
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
EXApplication:
|
||||
:path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios"
|
||||
:path: "../node_modules/expo-application/ios"
|
||||
EXConstants:
|
||||
:path: "../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios"
|
||||
:path: "../node_modules/expo-constants/ios"
|
||||
EXJSONUtils:
|
||||
:path: "../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios"
|
||||
:path: "../node_modules/expo-json-utils/ios"
|
||||
EXManifests:
|
||||
:path: "../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios"
|
||||
:path: "../node_modules/expo-manifests/ios"
|
||||
EXNotifications:
|
||||
:path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios"
|
||||
:path: "../node_modules/expo-notifications/ios"
|
||||
Expo:
|
||||
:path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo"
|
||||
:path: "../node_modules/expo"
|
||||
expo-dev-client:
|
||||
:path: "../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios"
|
||||
:path: "../node_modules/expo-dev-client/ios"
|
||||
expo-dev-launcher:
|
||||
:path: "../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher"
|
||||
:path: "../node_modules/expo-dev-launcher"
|
||||
expo-dev-menu:
|
||||
:path: "../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu"
|
||||
:path: "../node_modules/expo-dev-menu"
|
||||
expo-dev-menu-interface:
|
||||
:path: "../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios"
|
||||
:path: "../node_modules/expo-dev-menu-interface/ios"
|
||||
ExpoAsset:
|
||||
:path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios"
|
||||
:path: "../node_modules/expo-asset/ios"
|
||||
ExpoCrypto:
|
||||
:path: "../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios"
|
||||
:path: "../node_modules/expo-crypto/ios"
|
||||
ExpoDevice:
|
||||
:path: "../node_modules/.pnpm/expo-device@8.0.10_expo@54.0.32/node_modules/expo-device/ios"
|
||||
:path: "../node_modules/expo-device/ios"
|
||||
ExpoFileSystem:
|
||||
:path: "../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios"
|
||||
:path: "../node_modules/expo-file-system/ios"
|
||||
ExpoFont:
|
||||
:path: "../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios"
|
||||
:path: "../node_modules/expo-font/ios"
|
||||
ExpoHead:
|
||||
:path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios"
|
||||
:path: "../node_modules/expo-router/ios"
|
||||
ExpoKeepAwake:
|
||||
:path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios"
|
||||
:path: "../node_modules/expo-keep-awake/ios"
|
||||
ExpoLinearGradient:
|
||||
:path: "../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios"
|
||||
:path: "../node_modules/expo-linear-gradient/ios"
|
||||
ExpoLinking:
|
||||
:path: "../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios"
|
||||
:path: "../node_modules/expo-linking/ios"
|
||||
ExpoLocalization:
|
||||
:path: "../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios"
|
||||
:path: "../node_modules/expo-localization/ios"
|
||||
ExpoModulesCore:
|
||||
:path: "../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core"
|
||||
:path: "../node_modules/expo-modules-core"
|
||||
ExpoSplashScreen:
|
||||
:path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios"
|
||||
:path: "../node_modules/expo-splash-screen/ios"
|
||||
ExpoWebBrowser:
|
||||
:path: "../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios"
|
||||
:path: "../node_modules/expo-web-browser/ios"
|
||||
EXUpdatesInterface:
|
||||
:path: "../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios"
|
||||
:path: "../node_modules/expo-updates-interface/ios"
|
||||
FBLazyVector:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector"
|
||||
:path: "../node_modules/react-native/Libraries/FBLazyVector"
|
||||
hermes-engine:
|
||||
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
|
||||
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
|
||||
:tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782
|
||||
RCTDeprecation:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
|
||||
:path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
|
||||
RCTRequired:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Required"
|
||||
:path: "../node_modules/react-native/Libraries/Required"
|
||||
RCTTypeSafety:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/TypeSafety"
|
||||
:path: "../node_modules/react-native/Libraries/TypeSafety"
|
||||
React:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/"
|
||||
:path: "../node_modules/react-native/"
|
||||
React-callinvoker:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/callinvoker"
|
||||
:path: "../node_modules/react-native/ReactCommon/callinvoker"
|
||||
React-Core:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/"
|
||||
:path: "../node_modules/react-native/"
|
||||
React-Core-prebuilt:
|
||||
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React-Core-prebuilt.podspec"
|
||||
:podspec: "../node_modules/react-native/React-Core-prebuilt.podspec"
|
||||
React-CoreModules:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/CoreModules"
|
||||
:path: "../node_modules/react-native/React/CoreModules"
|
||||
React-cxxreact:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/cxxreact"
|
||||
:path: "../node_modules/react-native/ReactCommon/cxxreact"
|
||||
React-debug:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/debug"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/debug"
|
||||
React-defaultsnativemodule:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/defaults"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults"
|
||||
React-domnativemodule:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/dom"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom"
|
||||
React-Fabric:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
React-FabricComponents:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
React-FabricImage:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
React-featureflags:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/featureflags"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/featureflags"
|
||||
React-featureflagsnativemodule:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/featureflags"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags"
|
||||
React-graphics:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/graphics"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/graphics"
|
||||
React-hermes:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes"
|
||||
:path: "../node_modules/react-native/ReactCommon/hermes"
|
||||
React-idlecallbacksnativemodule:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks"
|
||||
React-ImageManager:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios"
|
||||
React-jserrorhandler:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jserrorhandler"
|
||||
:path: "../node_modules/react-native/ReactCommon/jserrorhandler"
|
||||
React-jsi:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsi"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsi"
|
||||
React-jsiexecutor:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsiexecutor"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsiexecutor"
|
||||
React-jsinspector:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern"
|
||||
React-jsinspectorcdp:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/cdp"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp"
|
||||
React-jsinspectornetwork:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/network"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network"
|
||||
React-jsinspectortracing:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/tracing"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing"
|
||||
React-jsitooling:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsitooling"
|
||||
:path: "../node_modules/react-native/ReactCommon/jsitooling"
|
||||
React-jsitracing:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes/executor/"
|
||||
:path: "../node_modules/react-native/ReactCommon/hermes/executor/"
|
||||
React-logger:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger"
|
||||
:path: "../node_modules/react-native/ReactCommon/logger"
|
||||
React-Mapbuffer:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
React-microtasksnativemodule:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
||||
react-native-safe-area-context:
|
||||
:path: "../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context"
|
||||
:path: "../node_modules/react-native-safe-area-context"
|
||||
React-NativeModulesApple:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
|
||||
React-oscompat:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat"
|
||||
:path: "../node_modules/react-native/ReactCommon/oscompat"
|
||||
React-perflogger:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger"
|
||||
:path: "../node_modules/react-native/ReactCommon/reactperflogger"
|
||||
React-performancetimeline:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/performance/timeline"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/performance/timeline"
|
||||
React-RCTActionSheet:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/ActionSheetIOS"
|
||||
:path: "../node_modules/react-native/Libraries/ActionSheetIOS"
|
||||
React-RCTAnimation:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/NativeAnimation"
|
||||
:path: "../node_modules/react-native/Libraries/NativeAnimation"
|
||||
React-RCTAppDelegate:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/AppDelegate"
|
||||
:path: "../node_modules/react-native/Libraries/AppDelegate"
|
||||
React-RCTBlob:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Blob"
|
||||
:path: "../node_modules/react-native/Libraries/Blob"
|
||||
React-RCTFabric:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React"
|
||||
:path: "../node_modules/react-native/React"
|
||||
React-RCTFBReactNativeSpec:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React"
|
||||
:path: "../node_modules/react-native/React"
|
||||
React-RCTImage:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Image"
|
||||
:path: "../node_modules/react-native/Libraries/Image"
|
||||
React-RCTLinking:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/LinkingIOS"
|
||||
:path: "../node_modules/react-native/Libraries/LinkingIOS"
|
||||
React-RCTNetwork:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Network"
|
||||
:path: "../node_modules/react-native/Libraries/Network"
|
||||
React-RCTRuntime:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/Runtime"
|
||||
:path: "../node_modules/react-native/React/Runtime"
|
||||
React-RCTSettings:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Settings"
|
||||
:path: "../node_modules/react-native/Libraries/Settings"
|
||||
React-RCTText:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Text"
|
||||
:path: "../node_modules/react-native/Libraries/Text"
|
||||
React-RCTVibration:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Vibration"
|
||||
:path: "../node_modules/react-native/Libraries/Vibration"
|
||||
React-rendererconsistency:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/consistency"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/consistency"
|
||||
React-renderercss:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/css"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/css"
|
||||
React-rendererdebug:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/debug"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/debug"
|
||||
React-RuntimeApple:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime/platform/ios"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios"
|
||||
React-RuntimeCore:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/runtime"
|
||||
React-runtimeexecutor:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/runtimeexecutor"
|
||||
:path: "../node_modules/react-native/ReactCommon/runtimeexecutor"
|
||||
React-RuntimeHermes:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/runtime"
|
||||
React-runtimescheduler:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler"
|
||||
React-timing:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/timing"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/timing"
|
||||
React-utils:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/utils"
|
||||
:path: "../node_modules/react-native/ReactCommon/react/utils"
|
||||
ReactAppDependencyProvider:
|
||||
:path: build/generated/ios
|
||||
ReactCodegen:
|
||||
:path: build/generated/ios
|
||||
ReactCommon:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon"
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
ReactNativeDependencies:
|
||||
:podspec: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
|
||||
RNCAsyncStorage:
|
||||
:path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage"
|
||||
:path: "../node_modules/@react-native-async-storage/async-storage"
|
||||
RNGestureHandler:
|
||||
:path: "../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1._tylda4qoo2jtxaj3472gn4luma/node_modules/react-native-gesture-handler"
|
||||
:path: "../node_modules/react-native-gesture-handler"
|
||||
RNReanimated:
|
||||
:path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated"
|
||||
:path: "../node_modules/react-native-reanimated"
|
||||
RNScreens:
|
||||
:path: "../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens"
|
||||
:path: "../node_modules/react-native-screens"
|
||||
RNSVG:
|
||||
:path: "../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg"
|
||||
:path: "../node_modules/react-native-svg"
|
||||
RNWorklets:
|
||||
:path: "../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets"
|
||||
:path: "../node_modules/react-native-worklets"
|
||||
Yoga:
|
||||
:path: "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga"
|
||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
|
||||
@@ -2628,15 +2628,15 @@ SPEC CHECKSUMS:
|
||||
React-timing: 03c7217455d2bff459b27a3811be25796b600f47
|
||||
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
|
||||
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
|
||||
ReactCodegen: 88a1f4643f15841573f833b895bfa2a0c6cb4e7f
|
||||
ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f
|
||||
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
||||
RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3
|
||||
RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c
|
||||
RNReanimated: 43f611f1c85c90e0273df7399bf1536f8e2bd125
|
||||
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
||||
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
||||
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad
|
||||
RNWorklets: 28ee7370ca8da356fcc914e3e68b97e9752196d2
|
||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||
|
||||
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 70;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -11,7 +11,8 @@
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */; };
|
||||
C0A1B2C3D4E5F60718293A4E /* Screen_page.png in Resources */ = {isa = PBXBuildFile; fileRef = C0A1B2C3D4E5F60718293A4D /* Screen_page.png */; };
|
||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
|
||||
@@ -53,10 +54,11 @@
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
|
||||
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
|
||||
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Screen_page.png; path = ../assets/images/Screen_page.png; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
E3328F0E595C1F4A244DF238 /* libPods-client.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-client.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -72,7 +74,7 @@
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
EmotionWidget.swift,
|
||||
@@ -83,7 +85,18 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
|
||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = "情绪小组件";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -120,6 +133,7 @@
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */,
|
||||
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
name = client;
|
||||
@@ -199,7 +213,7 @@
|
||||
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
|
||||
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
@@ -307,6 +321,7 @@
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||
C0A1B2C3D4E5F60718293A4E /* Screen_page.png in Resources */,
|
||||
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -466,7 +481,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
|
||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -621,7 +636,7 @@
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = NO;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -680,7 +695,7 @@
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = NO;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLegacy" image="SplashScreenLegacy" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
|
||||
<imageView id="EXPO-SplashScreen" userLabel="Screen_page" image="Screen_page" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
|
||||
<rect key="frame" x="0" y="0" width="414" height="736"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
@@ -37,7 +37,7 @@
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="SplashScreenLegacy" width="414" height="736"/>
|
||||
<image name="Screen_page" width="414" height="736"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
|
||||
1540
client/package-lock.json
generated
1540
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"grapheme-splitter": "^1.0.4",
|
||||
"i18next": "^25.8.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
@@ -38,6 +39,7 @@
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-svg-transformer": "^1.5.3",
|
||||
"react-native-text-size": "^4.0.0-rc.1",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { wrapText } from '../index';
|
||||
|
||||
describe('textWrap integration wrapText', () => {
|
||||
it('SYSTEM_DEFAULT:meta 标记 SYSTEM_DEFAULT 且仍返回 lines/wrappedText', async () => {
|
||||
const res = await wrapText({
|
||||
text: 'I am so tired',
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 1,
|
||||
maxLines: 1,
|
||||
overflowMode: 'SYSTEM_DEFAULT',
|
||||
// 不提供测量能力,逼迫走兜底
|
||||
fontSpec: null,
|
||||
measureWidthImpl: undefined,
|
||||
});
|
||||
|
||||
expect(res.lines.length).toBeGreaterThan(0);
|
||||
expect(res.wrappedText.length).toBeGreaterThan(0);
|
||||
expect(res.meta?.fallback_type).toBe('SYSTEM_DEFAULT');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { generateBreakpoints } from '../index';
|
||||
|
||||
function t(text: string, start: number): { text: string; start: number; end: number } {
|
||||
return { text, start, end: start + text.length };
|
||||
}
|
||||
|
||||
describe('textWrap breakpoint-candidates', () => {
|
||||
it('EN: tokens=[I,am,so,tired] -> pos=[1,2,3](升序)', () => {
|
||||
const tokens = [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)];
|
||||
const { breakpoints, meta } = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'EN',
|
||||
maxLines: 2,
|
||||
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [',', '。'], balanceRange: 3 },
|
||||
});
|
||||
|
||||
expect(breakpoints.map((b) => b.pos)).toEqual([1, 2, 3]);
|
||||
expect(meta.originalCount).toBe(3);
|
||||
expect(meta.finalCount).toBe(3);
|
||||
});
|
||||
|
||||
it('TC: 标点后断点(,)应生成 pos=i+1', () => {
|
||||
const tokens = [t('我', 0), t('好', 1), t('累', 2), t(',', 3), t('😮💨', 4)];
|
||||
const { breakpoints } = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'TC',
|
||||
maxLines: 2,
|
||||
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 0 },
|
||||
});
|
||||
|
||||
expect(breakpoints.some((b) => b.kind === 'PUNCT' && b.pos === 4)).toBe(true);
|
||||
});
|
||||
|
||||
it('TC: forbiddenBreakRanges(闭区间)命中必须剔除(含 start/end)', () => {
|
||||
const tokens = [t('我', 0), t('好', 1), t('累', 2), t(',', 3), t('😮💨', 4)];
|
||||
const { breakpoints } = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'TC',
|
||||
maxLines: 2,
|
||||
constraints: { forbiddenBreakRanges: [{ start: 4, end: 4 }] },
|
||||
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 0 },
|
||||
});
|
||||
|
||||
expect(breakpoints.some((b) => b.pos === 4)).toBe(false);
|
||||
});
|
||||
|
||||
it('TC: 去重规则:同 pos 优先保留 priority 更高者;同 priority 按 kind 序', () => {
|
||||
// 构造:同一个 pos=2 同时来自 SPACE(priority=20) 和 BALANCE(priority=5),必须保留 SPACE
|
||||
const tokens = [t('我', 0), t(' ', 1), t('好', 2)];
|
||||
const { breakpoints } = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'TC',
|
||||
maxLines: 2,
|
||||
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [], balanceRange: 2 },
|
||||
});
|
||||
|
||||
const b2 = breakpoints.find((b) => b.pos === 2);
|
||||
expect(b2?.kind).toBe('SPACE');
|
||||
expect(b2?.priority).toBe(20);
|
||||
});
|
||||
|
||||
it('TC: 裁剪上限生效且输出按 pos 升序(确定性)', () => {
|
||||
const tokens = Array.from({ length: 60 }).map((_, i) => t('哈', i));
|
||||
const res = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'TC',
|
||||
maxLines: 3,
|
||||
config: { tcMaxCandidateBreaks: 8, tcPunctuations: [], balanceRange: 6 },
|
||||
});
|
||||
|
||||
expect(res.breakpoints.length).toBe(8);
|
||||
// pos 升序
|
||||
for (let i = 1; i < res.breakpoints.length; i++) {
|
||||
expect(res.breakpoints[i]!.pos).toBeGreaterThanOrEqual(res.breakpoints[i - 1]!.pos);
|
||||
}
|
||||
// 确定性
|
||||
const res2 = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang: 'TC',
|
||||
maxLines: 3,
|
||||
config: { tcMaxCandidateBreaks: 8, tcPunctuations: [], balanceRange: 6 },
|
||||
});
|
||||
expect(res).toEqual(res2);
|
||||
});
|
||||
|
||||
it('边界:N<=1 时不输出任何候选断点', () => {
|
||||
const { breakpoints } = generateBreakpoints({
|
||||
tokens: [t('我', 0)] as any,
|
||||
lang: 'TC',
|
||||
maxLines: 2,
|
||||
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 3 },
|
||||
});
|
||||
expect(breakpoints).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
22
client/src/features/textWrap/breakpoints/enCandidates.ts
Normal file
22
client/src/features/textWrap/breakpoints/enCandidates.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Breakpoint } from './types';
|
||||
import type { Token } from '../core/types';
|
||||
|
||||
/**
|
||||
* EN 候选断点生成(极简派)
|
||||
*
|
||||
* 口径:
|
||||
* - tokens 仅为 WORD(不包含 SPACE token)
|
||||
* - 候选断点只生成在 `pos ∈ [1, N-1]`
|
||||
* - kind 固定为 SPACE,priority 固定为 10
|
||||
*/
|
||||
export function generateEnCandidates(tokens: Token[]): Breakpoint[] {
|
||||
const n = tokens.length;
|
||||
if (n <= 1) return [];
|
||||
|
||||
const out: Breakpoint[] = [];
|
||||
for (let pos = 1; pos <= n - 1; pos++) {
|
||||
out.push({ pos, kind: 'SPACE', priority: 10 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
89
client/src/features/textWrap/breakpoints/filterAndDedup.ts
Normal file
89
client/src/features/textWrap/breakpoints/filterAndDedup.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Breakpoint, BreakpointKind, BreakpointMeta, BreakpointConstraints } from './types';
|
||||
|
||||
const KIND_ORDER: Record<BreakpointKind, number> = {
|
||||
PUNCT: 0,
|
||||
SPACE: 1,
|
||||
BALANCE: 2,
|
||||
OTHER: 3,
|
||||
};
|
||||
|
||||
function betterBreakpoint(a: Breakpoint, b: Breakpoint): Breakpoint {
|
||||
if (a.priority !== b.priority) return a.priority > b.priority ? a : b;
|
||||
const ka = KIND_ORDER[a.kind] ?? 999;
|
||||
const kb = KIND_ORDER[b.kind] ?? 999;
|
||||
if (ka !== kb) return ka < kb ? a : b;
|
||||
// 完全相同优先级时,稳定选择 pos 更小者(但同 pos 才会进入该比较)
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* forbiddenBreakRanges 过滤(闭区间口径)
|
||||
* - start <= pos && pos <= end 命中则剔除
|
||||
*/
|
||||
export function filterForbiddenBreakRanges(
|
||||
breakpoints: Breakpoint[],
|
||||
constraints: BreakpointConstraints | undefined
|
||||
): Breakpoint[] {
|
||||
const ranges = constraints?.forbiddenBreakRanges;
|
||||
if (!ranges || ranges.length === 0) return breakpoints;
|
||||
|
||||
return breakpoints.filter((bp) => {
|
||||
for (const r of ranges) {
|
||||
const s = r.start | 0;
|
||||
const e = r.end | 0;
|
||||
if (s <= bp.pos && bp.pos <= e) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 去重:同 pos 只保留一个 breakpoint(priority 更高者优先;同 priority 按 kind 固定序)
|
||||
*/
|
||||
export function dedupByPos(breakpoints: Breakpoint[]): Breakpoint[] {
|
||||
const map = new Map<number, Breakpoint>();
|
||||
for (const bp of breakpoints) {
|
||||
const prev = map.get(bp.pos);
|
||||
if (!prev) {
|
||||
map.set(bp.pos, bp);
|
||||
continue;
|
||||
}
|
||||
map.set(bp.pos, betterBreakpoint(prev, bp));
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function distanceToNearestIdeal(pos: number, ideals: number[] | undefined): number {
|
||||
if (!ideals || ideals.length === 0) return 1_000_000_000;
|
||||
let best = 1_000_000_000;
|
||||
for (const p of ideals) {
|
||||
const d = Math.abs(pos - p);
|
||||
if (d < best) best = d;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* TC 裁剪(确定性排序后截断)
|
||||
* - priority desc
|
||||
* - distToIdeal asc
|
||||
* - pos asc
|
||||
*/
|
||||
export function pruneTcCandidates(breakpoints: Breakpoint[], ideals: number[], tcMaxCandidateBreaks: number): Breakpoint[] {
|
||||
if (breakpoints.length <= tcMaxCandidateBreaks) return breakpoints;
|
||||
|
||||
const sorted = [...breakpoints].sort((a, b) => {
|
||||
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||
const da = distanceToNearestIdeal(a.pos, ideals);
|
||||
const db = distanceToNearestIdeal(b.pos, ideals);
|
||||
if (da !== db) return da - db;
|
||||
return a.pos - b.pos;
|
||||
});
|
||||
|
||||
return sorted.slice(0, tcMaxCandidateBreaks);
|
||||
}
|
||||
|
||||
export function buildMeta(originalCount: number, finalCount: number): BreakpointMeta {
|
||||
return { originalCount, finalCount, pruned: finalCount < originalCount };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Breakpoint, BreakpointMeta, GenerateBreakpointsInput } from './types';
|
||||
import { generateEnCandidates } from './enCandidates';
|
||||
import { generateTcCandidates } from './tcCandidates';
|
||||
import { buildMeta, dedupByPos, filterForbiddenBreakRanges, pruneTcCandidates } from './filterAndDedup';
|
||||
|
||||
/**
|
||||
* 生成候选断点(确定性)
|
||||
*
|
||||
* 统一口径:
|
||||
* - 只输出 `pos ∈ [1, N-1]`
|
||||
* - forbiddenBreakRanges 使用闭区间过滤:start <= pos && pos <= end
|
||||
* - 去重按 priority 优先;priority 相同按 kind 固定序:PUNCT > SPACE > BALANCE > OTHER
|
||||
* - TC 超过上限时按固定排序裁剪,然后最终按 pos 升序输出
|
||||
*/
|
||||
export function generateBreakpoints(input: GenerateBreakpointsInput): { breakpoints: Breakpoint[]; meta: BreakpointMeta } {
|
||||
const tokens = input.tokens ?? [];
|
||||
const n = tokens.length;
|
||||
const lang = input.lang;
|
||||
|
||||
if (n <= 1) {
|
||||
return { breakpoints: [], meta: buildMeta(0, 0) };
|
||||
}
|
||||
|
||||
let raw: Breakpoint[] = [];
|
||||
let ideals: number[] = [];
|
||||
|
||||
if (lang === 'EN') {
|
||||
raw = generateEnCandidates(tokens);
|
||||
} else {
|
||||
const out = generateTcCandidates(tokens, input.maxLines, {
|
||||
tcPunctuations: input.config.tcPunctuations,
|
||||
balanceRange: input.config.balanceRange,
|
||||
});
|
||||
raw = out.candidates;
|
||||
ideals = out.idealPositions;
|
||||
}
|
||||
|
||||
const originalCount = raw.length;
|
||||
|
||||
// 过滤 forbiddenBreakRanges(闭区间)
|
||||
let filtered = filterForbiddenBreakRanges(raw, input.constraints);
|
||||
|
||||
// 去重(同 pos 保留最佳)
|
||||
filtered = dedupByPos(filtered);
|
||||
|
||||
// TC 裁剪(规模上限)
|
||||
if (lang === 'TC') {
|
||||
const max = Math.max(0, input.config.tcMaxCandidateBreaks | 0);
|
||||
if (max > 0) {
|
||||
filtered = pruneTcCandidates(filtered, ideals, max);
|
||||
}
|
||||
}
|
||||
|
||||
// 最终按 pos 升序输出
|
||||
filtered.sort((a, b) => a.pos - b.pos);
|
||||
|
||||
return { breakpoints: filtered, meta: buildMeta(originalCount, filtered.length) };
|
||||
}
|
||||
|
||||
11
client/src/features/textWrap/breakpoints/index.ts
Normal file
11
client/src/features/textWrap/breakpoints/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
Breakpoint,
|
||||
BreakpointConfig,
|
||||
BreakpointConstraints,
|
||||
BreakpointKind,
|
||||
BreakpointMeta,
|
||||
GenerateBreakpointsInput,
|
||||
} from './types';
|
||||
|
||||
export { generateBreakpoints } from './generateBreakpoints';
|
||||
|
||||
78
client/src/features/textWrap/breakpoints/tcCandidates.ts
Normal file
78
client/src/features/textWrap/breakpoints/tcCandidates.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Breakpoint } from './types';
|
||||
import type { Token } from '../core/types';
|
||||
|
||||
type TcCandidateConfig = {
|
||||
tcPunctuations: string[];
|
||||
balanceRange: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算 BALANCE 的 idealPos 列表(确定性简化版)。
|
||||
*
|
||||
* - N=tokens.length
|
||||
* - targetLines=min(maxLines,N)
|
||||
* - lineIndex in 1..targetLines-1:
|
||||
* idealPos=round(N*lineIndex/targetLines)
|
||||
*/
|
||||
export function computeTcIdealPositions(tokens: Token[], maxLines: number): number[] {
|
||||
const n = tokens.length;
|
||||
const targetLines = Math.max(1, Math.min(maxLines | 0, n));
|
||||
const ideals: number[] = [];
|
||||
|
||||
for (let lineIndex = 1; lineIndex <= targetLines - 1; lineIndex++) {
|
||||
const idealPos = Math.round((n * lineIndex) / targetLines);
|
||||
ideals.push(idealPos);
|
||||
}
|
||||
|
||||
// 去重并排序(稳定)
|
||||
ideals.sort((a, b) => a - b);
|
||||
return ideals.filter((v, idx) => idx === 0 || v !== ideals[idx - 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* TC 候选断点生成:
|
||||
* - 标点后:kind=PUNCT,priority=30
|
||||
* - 空格后:kind=SPACE,priority=20
|
||||
* - BALANCE:kind=BALANCE,priority=5(围绕 idealPos ± balanceRange)
|
||||
*
|
||||
* 注意:BALANCE 断点允许生成在短语 span 内,是否可用交给评分阶段强惩罚淘汰。
|
||||
*/
|
||||
export function generateTcCandidates(tokens: Token[], maxLines: number, config: TcCandidateConfig): { candidates: Breakpoint[]; idealPositions: number[] } {
|
||||
const n = tokens.length;
|
||||
if (n <= 1) return { candidates: [], idealPositions: [] };
|
||||
|
||||
const punctSet = new Set(config.tcPunctuations);
|
||||
const candidates: Breakpoint[] = [];
|
||||
|
||||
// PUNCT / SPACE(扫描 token)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = tokens[i]?.text ?? '';
|
||||
const pos = i + 1;
|
||||
|
||||
// 只允许行内断点
|
||||
if (pos < 1 || pos > n - 1) continue;
|
||||
|
||||
if (punctSet.has(t)) {
|
||||
candidates.push({ pos, kind: 'PUNCT', priority: 30 });
|
||||
}
|
||||
|
||||
if (t === ' ') {
|
||||
candidates.push({ pos, kind: 'SPACE', priority: 20 });
|
||||
}
|
||||
}
|
||||
|
||||
// BALANCE
|
||||
const idealPositions = computeTcIdealPositions(tokens, maxLines);
|
||||
const range = Math.max(0, config.balanceRange | 0);
|
||||
if (range > 0 && idealPositions.length > 0) {
|
||||
for (const ideal of idealPositions) {
|
||||
for (let pos = ideal - range; pos <= ideal + range; pos++) {
|
||||
if (pos < 1 || pos > n - 1) continue;
|
||||
candidates.push({ pos, kind: 'BALANCE', priority: 5 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { candidates, idealPositions };
|
||||
}
|
||||
|
||||
37
client/src/features/textWrap/breakpoints/types.ts
Normal file
37
client/src/features/textWrap/breakpoints/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Token } from '../core/types';
|
||||
|
||||
export type BreakpointKind = 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER';
|
||||
|
||||
export type Breakpoint = {
|
||||
/** 断点位置(token 边界):切分为 [0..pos) + [pos..N) */
|
||||
pos: number;
|
||||
kind: BreakpointKind;
|
||||
/** 候选优先级(用于去重/裁剪阶段),越大越优先 */
|
||||
priority: number;
|
||||
};
|
||||
|
||||
export type BreakpointMeta = {
|
||||
pruned: boolean;
|
||||
originalCount: number;
|
||||
finalCount: number;
|
||||
};
|
||||
|
||||
export type BreakpointConstraints = {
|
||||
protectedPhrases?: string[];
|
||||
forbiddenBreakRanges?: Array<{ start: number; end: number }>;
|
||||
};
|
||||
|
||||
export type BreakpointConfig = {
|
||||
tcMaxCandidateBreaks: number;
|
||||
tcPunctuations: string[];
|
||||
balanceRange: number;
|
||||
};
|
||||
|
||||
export type GenerateBreakpointsInput = {
|
||||
tokens: Token[];
|
||||
lang: 'TC' | 'EN';
|
||||
maxLines: number;
|
||||
constraints?: BreakpointConstraints;
|
||||
config: BreakpointConfig;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_PUNCTUATION_STRIP_SET_EN,
|
||||
compareBreaksLexicographically,
|
||||
joinTokens,
|
||||
matchENKeyword,
|
||||
normalizeENKeyword,
|
||||
normalizeWhitespace,
|
||||
tokenizeEN,
|
||||
} from '../index';
|
||||
|
||||
describe('textWrap core-contract', () => {
|
||||
it('normalizeWhitespace(NORMALIZE): 折叠空白 + 去首尾', () => {
|
||||
const out = normalizeWhitespace(' a b \n c ', 'NORMALIZE');
|
||||
expect(out.normalizedText).toBe('a b c');
|
||||
expect(out.hadMultiWhitespace).toBe(true);
|
||||
});
|
||||
|
||||
it('tokenizeEN: 只按空白分词(极简派,不拆标点)', () => {
|
||||
const { normalizedText } = normalizeWhitespace('I am so tired.', 'NORMALIZE');
|
||||
const tokens = tokenizeEN(normalizedText);
|
||||
expect(tokens.map((t) => t.text)).toEqual(['I', 'am', 'so', 'tired.']);
|
||||
});
|
||||
|
||||
it('joinTokens: 默认用单空格重组', () => {
|
||||
const { normalizedText } = normalizeWhitespace('I am so tired', 'NORMALIZE');
|
||||
const tokens = tokenizeEN(normalizedText);
|
||||
expect(joinTokens(tokens, 0, 2)).toBe('I am');
|
||||
expect(joinTokens(tokens, 2, 4)).toBe('so tired');
|
||||
});
|
||||
|
||||
it('normalizeENKeyword/matchENKeyword: 全词等值匹配,不做 substring', () => {
|
||||
const config = { punctuationStripSetEN: DEFAULT_PUNCTUATION_STRIP_SET_EN };
|
||||
|
||||
expect(normalizeENKeyword('BUT,', config)).toBe('but');
|
||||
expect(matchENKeyword('but,', 'but', config)).toBe(true);
|
||||
expect(matchENKeyword('rebuttal', 'but', config)).toBe(false);
|
||||
});
|
||||
|
||||
it('compareBreaksLexicographically: 字典序 + 短数组优先', () => {
|
||||
expect(compareBreaksLexicographically([2], [3])).toBeLessThan(0);
|
||||
expect(compareBreaksLexicographically([2], [2, 5])).toBeLessThan(0);
|
||||
expect(compareBreaksLexicographically([2, 3], [2])).toBeGreaterThan(0);
|
||||
expect(compareBreaksLexicographically([2, 3], [2, 3])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
25
client/src/features/textWrap/core/compare.ts
Normal file
25
client/src/features/textWrap/core/compare.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* breaks[] 字典序比较(确定性口径)
|
||||
*
|
||||
* 规则(必须):
|
||||
* - 从 index=0 起逐项比较:
|
||||
* - 首个不同元素更小者视为更小
|
||||
* - 若公共前缀完全相同:
|
||||
* - 更短的数组视为更小
|
||||
*/
|
||||
export function compareBreaksLexicographically(a: number[], b: number[]): number {
|
||||
const aa = Array.isArray(a) ? a : [];
|
||||
const bb = Array.isArray(b) ? b : [];
|
||||
|
||||
const n = Math.min(aa.length, bb.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = aa[i]!;
|
||||
const y = bb[i]!;
|
||||
if (x === y) continue;
|
||||
return x < y ? -1 : 1;
|
||||
}
|
||||
|
||||
if (aa.length === bb.length) return 0;
|
||||
return aa.length < bb.length ? -1 : 1;
|
||||
}
|
||||
|
||||
68
client/src/features/textWrap/core/enKeyword.ts
Normal file
68
client/src/features/textWrap/core/enKeyword.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { CoreConfig } from './types';
|
||||
|
||||
/**
|
||||
* EN 常见标点(默认集合)
|
||||
*
|
||||
* 说明:此集合只用于“关键词命中判定”的两端 strip,不影响 tokenize(仍为极简派)。
|
||||
* 必须全端一致;后续若扩展,也必须通过 configVersion 管理。
|
||||
*/
|
||||
export const DEFAULT_PUNCTUATION_STRIP_SET_EN: string[] = [
|
||||
',',
|
||||
'.',
|
||||
'!',
|
||||
'?',
|
||||
':',
|
||||
';',
|
||||
'"',
|
||||
"'",
|
||||
'…',
|
||||
'—',
|
||||
'–',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
];
|
||||
|
||||
function buildStripSet(config?: Pick<CoreConfig, 'punctuationStripSetEN'>): Set<string> {
|
||||
const list = config?.punctuationStripSetEN?.length ? config.punctuationStripSetEN : DEFAULT_PUNCTUATION_STRIP_SET_EN;
|
||||
return new Set(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化一个 token,用于 EN 关键词命中(全词等值匹配)。
|
||||
*
|
||||
* 口径(必须):
|
||||
* - lowercase
|
||||
* - strip 两端常见标点(可重复剥离,例如 `"\"but,\""`)
|
||||
* - 不允许 substring/contains
|
||||
*/
|
||||
export function normalizeENKeyword(tokenText: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): string {
|
||||
const stripSet = buildStripSet(config);
|
||||
|
||||
let s = String(tokenText ?? '').toLowerCase();
|
||||
if (!s) return '';
|
||||
|
||||
// 仅剥离两端的“常见标点”,内部字符不处理
|
||||
let start = 0;
|
||||
let end = s.length;
|
||||
|
||||
while (start < end && stripSet.has(s[start]!)) start++;
|
||||
while (end > start && stripSet.has(s[end - 1]!)) end--;
|
||||
|
||||
// 如果剥离后还剩空白,再做一次 trim(避免 `"but "` 这种输入)
|
||||
return s.slice(start, end).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* EN 关键词命中:全词等值匹配(禁止 substring)。
|
||||
*/
|
||||
export function matchENKeyword(tokenText: string, keyword: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): boolean {
|
||||
const a = normalizeENKeyword(tokenText, config);
|
||||
const b = normalizeENKeyword(keyword, config);
|
||||
if (!a || !b) return false;
|
||||
return a === b;
|
||||
}
|
||||
|
||||
8
client/src/features/textWrap/core/index.ts
Normal file
8
client/src/features/textWrap/core/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { CoreConfig, Lang, NormalizeWhitespaceResult, Token, WhitespacePolicy } from './types';
|
||||
|
||||
export { normalizeWhitespace } from './normalizeWhitespace';
|
||||
export { tokenizeEN } from './tokenizeEN';
|
||||
export { joinTokens } from './joinTokens';
|
||||
export { DEFAULT_PUNCTUATION_STRIP_SET_EN, matchENKeyword, normalizeENKeyword } from './enKeyword';
|
||||
export { compareBreaksLexicographically } from './compare';
|
||||
|
||||
37
client/src/features/textWrap/core/joinTokens.ts
Normal file
37
client/src/features/textWrap/core/joinTokens.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Token } from './types';
|
||||
|
||||
/**
|
||||
* 从 token 区间 `[start..end)` 重组回文本(跨端口径)。
|
||||
*
|
||||
* - 默认:用单空格 join(适用于 whitespacePolicy=NORMALIZE)
|
||||
* - 若传入 rawSeparators:按原始分隔符拼接(适用于 whitespacePolicy=PRESERVE)
|
||||
*
|
||||
* 说明:
|
||||
* - `start/end` 为半开区间
|
||||
* - 空区间返回空字符串;上层需要用硬约束避免空行
|
||||
*/
|
||||
export function joinTokens(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
|
||||
const s = Math.max(0, start | 0);
|
||||
const e = Math.max(0, end | 0);
|
||||
if (!Array.isArray(tokens) || tokens.length === 0) return '';
|
||||
if (s >= e) return '';
|
||||
|
||||
const slice = tokens.slice(s, e).map((t) => t.text);
|
||||
if (!rawSeparators) {
|
||||
return slice.join(' ');
|
||||
}
|
||||
|
||||
// rawSeparators[i] 表示 tokens[i] 与 tokens[i+1] 之间的分隔符
|
||||
// 这里只实现最小可用:若 separators 缺失则回退到单空格。
|
||||
let out = '';
|
||||
for (let idx = s; idx < e; idx++) {
|
||||
const t = tokens[idx];
|
||||
if (!t) continue;
|
||||
out += t.text;
|
||||
if (idx < e - 1) {
|
||||
out += rawSeparators[idx] ?? ' ';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
28
client/src/features/textWrap/core/normalizeWhitespace.ts
Normal file
28
client/src/features/textWrap/core/normalizeWhitespace.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { NormalizeWhitespaceResult, WhitespacePolicy } from './types';
|
||||
|
||||
/**
|
||||
* 空白归一化(跨端口径)
|
||||
*
|
||||
* - NORMALIZE:折叠连续空白为单空格,并去首尾空白
|
||||
* - PRESERVE:仅去首尾空白(内部空白保持原样)
|
||||
*
|
||||
* 注意:该函数会改变输入文本(至少会 trim),必须作为“算法契约”的一部分固定下来。
|
||||
*/
|
||||
export function normalizeWhitespace(text: string, policy: WhitespacePolicy = 'NORMALIZE'): NormalizeWhitespaceResult {
|
||||
const input = String(text ?? '');
|
||||
|
||||
if (policy === 'PRESERVE') {
|
||||
const trimmed = input.trim();
|
||||
// 只要发生 trim,或内部存在非单空格的分隔(如 \n/\t/多个空格),都算 hadMultiWhitespace
|
||||
const hadMultiWhitespace = trimmed !== input || /[\t\r\n]/.test(trimmed) || / +/.test(trimmed);
|
||||
return { normalizedText: trimmed, hadMultiWhitespace };
|
||||
}
|
||||
|
||||
// NORMALIZE:把任意连续空白折叠为 1 个空格,并去首尾空白
|
||||
// 说明:这里使用 \s+ 覆盖空格/换行/制表等;跨端需要保证采用同等语义的实现。
|
||||
const trimmed = input.trim();
|
||||
const collapsed = trimmed.replace(/\s+/g, ' ');
|
||||
const hadMultiWhitespace = collapsed !== input;
|
||||
return { normalizedText: collapsed, hadMultiWhitespace };
|
||||
}
|
||||
|
||||
38
client/src/features/textWrap/core/tokenizeEN.ts
Normal file
38
client/src/features/textWrap/core/tokenizeEN.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Token } from './types';
|
||||
|
||||
/**
|
||||
* EN tokenize(极简派)
|
||||
*
|
||||
* 口径:
|
||||
* - tokens 只包含 WORD,不生成 SPACE token
|
||||
* - 标点视为“词内字符”,不额外拆分(例如 "tired."、"Wait..."、"hello—world" 都是一个 token)
|
||||
* - 断点只允许发生在“词与词之间”(由上层生成 breakpoint 时遵守)
|
||||
*
|
||||
* start/end 索引以 normalizedText 为基准(建议先调用 normalizeWhitespace)。
|
||||
*/
|
||||
export function tokenizeEN(normalizedText: string): Token[] {
|
||||
const s = String(normalizedText ?? '');
|
||||
if (!s) return [];
|
||||
|
||||
const tokens: Token[] = [];
|
||||
|
||||
// 由于 NORMALIZE 模式下空白都折叠为单空格,这里按空格扫描即可。
|
||||
// 为了稳健性,也允许出现意外多空格(会跳过空段)。
|
||||
let i = 0;
|
||||
const n = s.length;
|
||||
|
||||
while (i < n) {
|
||||
// 跳过空格(或其他空白字符)
|
||||
while (i < n && /\s/.test(s[i]!)) i++;
|
||||
if (i >= n) break;
|
||||
|
||||
const start = i;
|
||||
while (i < n && !/\s/.test(s[i]!)) i++;
|
||||
const end = i;
|
||||
|
||||
tokens.push({ text: s.slice(start, end), start, end });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
40
client/src/features/textWrap/core/types.ts
Normal file
40
client/src/features/textWrap/core/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Text Wrap - core-contract
|
||||
*
|
||||
* 本文件定义“跨端一致的基础口径”所需的最小类型集合。
|
||||
* 注意:这里的索引(start/end)默认以 normalizedText(归一化后的文本)为基准。
|
||||
*/
|
||||
|
||||
export type Lang = 'TC' | 'EN';
|
||||
|
||||
export type WhitespacePolicy = 'NORMALIZE' | 'PRESERVE';
|
||||
|
||||
export type Token = {
|
||||
/** token 的原始文本(EN:词;TC:字符簇) */
|
||||
text: string;
|
||||
/** token 在 normalizedText 中的起始索引(包含) */
|
||||
start: number;
|
||||
/** token 在 normalizedText 中的结束索引(不包含) */
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type NormalizeWhitespaceResult = {
|
||||
/**
|
||||
* 归一化后的文本。
|
||||
* - NORMALIZE:折叠连续空白为单空格,并去首尾空白
|
||||
* - PRESERVE:仅去首尾空白(内部空白保持原样)
|
||||
*/
|
||||
normalizedText: string;
|
||||
/** 是否发生过“空白折叠/trim/非单空格分隔”等(用于后续 meta 打点) */
|
||||
hadMultiWhitespace: boolean;
|
||||
};
|
||||
|
||||
export type CoreConfig = {
|
||||
whitespacePolicy: WhitespacePolicy;
|
||||
/**
|
||||
* EN 关键词命中:两端可剥离的常见标点集合(必须全端一致)。
|
||||
* 来源建议与默认值参考:`设计说明文档/文档换行算法.md`(v1.2.1)2.3.1A-1
|
||||
*/
|
||||
punctuationStripSetEN: string[];
|
||||
};
|
||||
|
||||
97
client/src/features/textWrap/golden/__tests__/golden.test.ts
Normal file
97
client/src/features/textWrap/golden/__tests__/golden.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Token } from '../../core/types';
|
||||
import { normalizeWhitespace, tokenizeEN } from '../../core/index';
|
||||
import { segmentGraphemes } from '../../grapheme/index';
|
||||
import { wrapText } from '../../index';
|
||||
import { GOLDEN_CASES } from '../fixtures';
|
||||
|
||||
function tokenizeTC(normalizedText: string): Token[] {
|
||||
const { clusters } = segmentGraphemes(normalizedText, 'PREFERRED');
|
||||
const tokens: Token[] = [];
|
||||
let idx = 0;
|
||||
for (const c of clusters) {
|
||||
const start = idx;
|
||||
idx += c.length;
|
||||
tokens.push({ text: c, start, end: idx });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async function wrapTextHarness(args: {
|
||||
text: string;
|
||||
lang: 'TC' | 'EN';
|
||||
context: 'APP' | 'WIDGET';
|
||||
availableWidth: number;
|
||||
maxLines: number;
|
||||
}): Promise<{ ok: true; lines: string[]; wrappedText: string } | { ok: false; detail: any }> {
|
||||
const out = await wrapText({
|
||||
text: args.text,
|
||||
lang: args.lang,
|
||||
context: args.context,
|
||||
availableWidth: args.availableWidth,
|
||||
maxLines: args.maxLines,
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: async ({ text }) => text.length,
|
||||
contextProfile: `${args.context}|golden`,
|
||||
lineMode: 'AUTO',
|
||||
debug: true,
|
||||
});
|
||||
|
||||
// wrapText 统一返回成功形态;若进入 SYSTEM_DEFAULT 等兜底,meta 会标记
|
||||
return { ok: true, lines: out.lines, wrappedText: out.wrappedText };
|
||||
}
|
||||
|
||||
describe('textWrap golden-tests', () => {
|
||||
for (const c of GOLDEN_CASES) {
|
||||
it(`golden:${c.id}`, async () => {
|
||||
const res = await wrapTextHarness({
|
||||
text: c.text,
|
||||
lang: c.lang,
|
||||
context: c.context,
|
||||
availableWidth: c.availableWidth,
|
||||
maxLines: c.maxLines,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`golden case failed: ${c.id}\n${JSON.stringify(res.detail, null, 2)}`);
|
||||
}
|
||||
if (res.ok) {
|
||||
expect(res.lines).toEqual(c.expected.lines);
|
||||
expect(res.wrappedText).toBe(c.expected.wrappedText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('property: 确定性(同输入多次调用一致)', async () => {
|
||||
const args = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, availableWidth: 6, maxLines: 2 };
|
||||
const a = await wrapTextHarness(args);
|
||||
const b = await wrapTextHarness(args);
|
||||
const c = await wrapTextHarness(args);
|
||||
expect(a).toEqual(b);
|
||||
expect(b).toEqual(c);
|
||||
});
|
||||
|
||||
it('property: 近似单调性(availableWidth 变小不会让最大行宽变大)', async () => {
|
||||
const base = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, maxLines: 2 };
|
||||
const wide = await wrapTextHarness({ ...base, availableWidth: 20 });
|
||||
const narrow = await wrapTextHarness({ ...base, availableWidth: 7 });
|
||||
|
||||
if (!wide.ok || !narrow.ok) {
|
||||
throw new Error(`property failed: expected both ok\nwide=${JSON.stringify(wide)}\nnarrow=${JSON.stringify(narrow)}`);
|
||||
}
|
||||
|
||||
const maxW = (lines: string[]) => Math.max(...lines.map((s) => s.length));
|
||||
expect(maxW(narrow.lines)).toBeLessThanOrEqual(maxW(wide.lines));
|
||||
});
|
||||
|
||||
it('property: maxLines 不变差(maxLines 增加不应从可解变无解)', async () => {
|
||||
const base = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, availableWidth: 7 };
|
||||
const a = await wrapTextHarness({ ...base, maxLines: 2 });
|
||||
const b = await wrapTextHarness({ ...base, maxLines: 3 });
|
||||
|
||||
expect(a.ok).toBe(true);
|
||||
expect(b.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
56
client/src/features/textWrap/golden/fixtures.ts
Normal file
56
client/src/features/textWrap/golden/fixtures.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export type GoldenCase = {
|
||||
id: string;
|
||||
text: string;
|
||||
lang: 'TC' | 'EN';
|
||||
context: 'APP' | 'WIDGET';
|
||||
availableWidth: number;
|
||||
maxLines: number;
|
||||
expected: { lines: string[]; wrappedText: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Golden fixtures(首版最小可运行集)
|
||||
*
|
||||
* 说明:
|
||||
* - APP:测量 mock=string.length,因此 availableWidth 也是“字符数单位”
|
||||
* - WIDGET:widthMode=APPROX,因此 availableWidth 是“token 数单位”
|
||||
*/
|
||||
export const GOLDEN_CASES: GoldenCase[] = [
|
||||
{
|
||||
id: 'en_app_simple_2lines',
|
||||
text: 'I am so tired',
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 7,
|
||||
maxLines: 2,
|
||||
expected: { lines: ['I am so', 'tired'], wrappedText: 'I am so\ntired' },
|
||||
},
|
||||
{
|
||||
id: 'en_app_punct_keyword',
|
||||
text: 'but, still ok',
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 9,
|
||||
maxLines: 2,
|
||||
expected: { lines: ['but,', 'still ok'], wrappedText: 'but,\nstill ok' },
|
||||
},
|
||||
{
|
||||
id: 'tc_app_punct_2lines',
|
||||
text: '我好累,😮💨',
|
||||
lang: 'TC',
|
||||
context: 'APP',
|
||||
availableWidth: 6,
|
||||
maxLines: 2,
|
||||
expected: { lines: ['我好累,', '😮💨'], wrappedText: '我好累,\n😮💨' },
|
||||
},
|
||||
{
|
||||
id: 'tc_widget_approx_2lines',
|
||||
text: '我好累',
|
||||
lang: 'TC',
|
||||
context: 'WIDGET',
|
||||
availableWidth: 2,
|
||||
maxLines: 2,
|
||||
expected: { lines: ['我', '好累'], wrappedText: '我\n好累' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { segmentGraphemes } from '../index';
|
||||
|
||||
const REQUIRED_SINGLE_CLUSTER_CASES: Array<{ name: string; input: string }> = [
|
||||
{ name: 'ZWJ family', input: '👨👩👧👦' },
|
||||
{ name: 'flag', input: '🇸🇬' },
|
||||
{ name: 'skin tone', input: '👍🏽' },
|
||||
{ name: 'ZWJ + variation', input: '😮💨' },
|
||||
{ name: 'combining mark', input: 'e\u0301' }, // é(组合形式)
|
||||
];
|
||||
|
||||
describe('textWrap grapheme-segmentation', () => {
|
||||
it('基础性质:空字符串 -> []', () => {
|
||||
const out = segmentGraphemes('', 'PREFERRED');
|
||||
expect(out.clusters).toEqual([]);
|
||||
expect(out.clusters.join('')).toBe('');
|
||||
});
|
||||
|
||||
it('基础性质:可逆性(clusters.join("") === input)', () => {
|
||||
const input = '我好累😮💨';
|
||||
const out = segmentGraphemes(input, 'PREFERRED');
|
||||
expect(out.clusters.join('')).toBe(input);
|
||||
});
|
||||
|
||||
it('基础性质:确定性(同输入同输出)', () => {
|
||||
const input = '👨👩👧👦🇸🇬👍🏽😮💨e\u0301';
|
||||
const a = segmentGraphemes(input, 'PREFERRED');
|
||||
const b = segmentGraphemes(input, 'PREFERRED');
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('必测样例:在 PREFERRED 下必须“不拆”为单个 cluster', () => {
|
||||
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||
const out = segmentGraphemes(c.input, 'PREFERRED');
|
||||
expect(out.clusters.join('')).toBe(c.input);
|
||||
expect(out.clusters.length).toBe(1);
|
||||
expect(out.clusters[0]).toBe(c.input);
|
||||
}
|
||||
});
|
||||
|
||||
it('必测样例:在强制 FALLBACK 下必须“不拆”为单个 cluster', () => {
|
||||
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||
const out = segmentGraphemes(c.input, 'FALLBACK');
|
||||
expect(out.meta.strategy).toBe('FALLBACK');
|
||||
expect(out.meta.hadFallback).toBe(true);
|
||||
expect(out.clusters.join('')).toBe(c.input);
|
||||
expect(out.clusters.length).toBe(1);
|
||||
expect(out.clusters[0]).toBe(c.input);
|
||||
}
|
||||
});
|
||||
|
||||
it('跨策略一致性:若 Intl.Segmenter 可用,则 PREFERRED 与 FALLBACK 输出应一致', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasIntl = typeof (globalThis as any)?.Intl?.Segmenter === 'function';
|
||||
if (!hasIntl) return;
|
||||
|
||||
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||
const preferred = segmentGraphemes(c.input, 'PREFERRED');
|
||||
const fallback = segmentGraphemes(c.input, 'FALLBACK');
|
||||
expect(preferred.clusters).toEqual(fallback.clusters);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
9
client/src/features/textWrap/grapheme/index.ts
Normal file
9
client/src/features/textWrap/grapheme/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
GraphemeSegmentationMeta,
|
||||
GraphemeSegmentationMode,
|
||||
GraphemeSegmentationResult,
|
||||
GraphemeSegmentationStrategy,
|
||||
} from './types';
|
||||
|
||||
export { segmentGraphemes } from './segmentGraphemes';
|
||||
|
||||
42
client/src/features/textWrap/grapheme/segmentGraphemes.ts
Normal file
42
client/src/features/textWrap/grapheme/segmentGraphemes.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { GraphemeSegmentationMode, GraphemeSegmentationResult } from './types';
|
||||
import { canUseIntlSegmenter, segmentWithIntlSegmenter } from './strategies/intlSegmenter';
|
||||
import { segmentWithFallback } from './strategies/fallback';
|
||||
|
||||
/**
|
||||
* 把字符串切分为 grapheme clusters(字符簇)。
|
||||
*
|
||||
* 契约(必须):
|
||||
* - clusters.join('') === text(不允许丢字符/改顺序)
|
||||
* - text=='' 时 clusters==[]
|
||||
* - 任何异常都必须降级到 fallback(保证可用性)
|
||||
*/
|
||||
export function segmentGraphemes(text: string, mode: GraphemeSegmentationMode = 'PREFERRED'): GraphemeSegmentationResult {
|
||||
const input = String(text ?? '');
|
||||
if (input === '') {
|
||||
return { clusters: [], meta: { strategy: 'FALLBACK', hadFallback: mode === 'FALLBACK' } };
|
||||
}
|
||||
|
||||
// 强制 fallback
|
||||
if (mode === 'FALLBACK') {
|
||||
const clusters = segmentWithFallback(input);
|
||||
return { clusters, meta: { strategy: 'FALLBACK', hadFallback: true } };
|
||||
}
|
||||
|
||||
// 优先 Intl.Segmenter
|
||||
if (canUseIntlSegmenter()) {
|
||||
try {
|
||||
const clusters = segmentWithIntlSegmenter(input);
|
||||
// 防御:必须可逆
|
||||
if (clusters.length > 0 && clusters.join('') === input) {
|
||||
return { clusters, meta: { strategy: 'INTL_SEGMENTER', hadFallback: false } };
|
||||
}
|
||||
// 若结果异常(空/丢字符),走 fallback
|
||||
} catch {
|
||||
// ignore -> fallback
|
||||
}
|
||||
}
|
||||
|
||||
const clusters = segmentWithFallback(input);
|
||||
return { clusters, meta: { strategy: 'FALLBACK', hadFallback: true } };
|
||||
}
|
||||
|
||||
81
client/src/features/textWrap/grapheme/strategies/fallback.ts
Normal file
81
client/src/features/textWrap/grapheme/strategies/fallback.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import GraphemeSplitter from 'grapheme-splitter';
|
||||
|
||||
/**
|
||||
* fallback 策略:使用 grapheme-splitter 分割 grapheme clusters。
|
||||
*
|
||||
* 选择原因:
|
||||
* - 避免手写不完整的 Unicode 规则导致“漏拆/误拆”
|
||||
* - 作为 Intl.Segmenter 不可用时的稳定兜底
|
||||
*
|
||||
* 注意:
|
||||
* - grapheme-splitter 对少数较新的 emoji ZWJ 序列支持可能不完整(例如 `😮💨`)。
|
||||
* - 为满足本项目“至少不拆 ZWJ sequence/VS16/肤色修饰符/组合字符”的最低要求,这里做一层确定性的后处理合并。
|
||||
*/
|
||||
export function segmentWithFallback(text: string): string[] {
|
||||
const splitter = new GraphemeSplitter();
|
||||
const raw = splitter.splitGraphemes(text);
|
||||
return mergeFallbackClusters(raw);
|
||||
}
|
||||
|
||||
const ZWJ = '\u200D';
|
||||
|
||||
function firstCodePoint(s: string): number | null {
|
||||
if (!s) return null;
|
||||
const cp = s.codePointAt(0);
|
||||
return typeof cp === 'number' ? cp : null;
|
||||
}
|
||||
|
||||
function isVariationSelector(cp: number): boolean {
|
||||
// VS15/VS16
|
||||
return cp === 0xfe0e || cp === 0xfe0f;
|
||||
}
|
||||
|
||||
function isSkinToneModifier(cp: number): boolean {
|
||||
return cp >= 0x1f3fb && cp <= 0x1f3ff;
|
||||
}
|
||||
|
||||
function isCombiningMark(cp: number): boolean {
|
||||
// 常见 combining marks 范围(覆盖 `e\u0301` 等)
|
||||
return (
|
||||
(cp >= 0x0300 && cp <= 0x036f) ||
|
||||
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
||||
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
||||
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
||||
(cp >= 0xfe20 && cp <= 0xfe2f)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 fallback 输出做“最低可用”合并:
|
||||
* - 若上一个 cluster 以 ZWJ 结尾,则必须与下一个合并(ZWJ sequence)
|
||||
* - 若下一个 cluster 以 VS/肤色修饰符/combining mark 开头,也与上一个合并
|
||||
*
|
||||
* 目标:满足文档列出的“不拆”组合要求,且行为完全确定性。
|
||||
*/
|
||||
function mergeFallbackClusters(raw: string[]): string[] {
|
||||
if (!raw.length) return raw;
|
||||
|
||||
const out: string[] = [];
|
||||
let buf = raw[0] ?? '';
|
||||
|
||||
for (let i = 1; i < raw.length; i++) {
|
||||
const next = raw[i] ?? '';
|
||||
const nextCp = firstCodePoint(next);
|
||||
|
||||
const shouldMerge =
|
||||
buf.endsWith(ZWJ) ||
|
||||
(nextCp !== null && (isVariationSelector(nextCp) || isSkinToneModifier(nextCp) || isCombiningMark(nextCp)));
|
||||
|
||||
if (shouldMerge) {
|
||||
buf += next;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(buf);
|
||||
buf = next;
|
||||
}
|
||||
|
||||
out.push(buf);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Intl.Segmenter 策略(优先)
|
||||
*
|
||||
* 注意:不同 JS 引擎/版本对 Intl.Segmenter 的支持可能不同,调用方必须捕获异常并降级。
|
||||
*/
|
||||
|
||||
export function canUseIntlSegmenter(): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Seg = (globalThis as any)?.Intl?.Segmenter;
|
||||
return typeof Seg === 'function';
|
||||
}
|
||||
|
||||
export function segmentWithIntlSegmenter(text: string): string[] {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Segmenter = (globalThis as any).Intl.Segmenter as new (locales?: string | string[], options?: any) => any;
|
||||
|
||||
// 用 zh-Hant 仅用于选择合适的 locale;grapheme 分割应与语言本身关系不大,但保持固定输入更易对齐跨端。
|
||||
const seg = new Segmenter('zh-Hant', { granularity: 'grapheme' });
|
||||
const it = seg.segment(text);
|
||||
|
||||
const clusters: string[] = [];
|
||||
for (const part of it) {
|
||||
// part: { segment: string, index: number, input: string, isWordLike?: boolean }
|
||||
clusters.push(part.segment);
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
21
client/src/features/textWrap/grapheme/types.ts
Normal file
21
client/src/features/textWrap/grapheme/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Text Wrap - grapheme-segmentation
|
||||
*
|
||||
* 本模块只负责把字符串切成“字符簇(grapheme clusters)”数组。
|
||||
* 后续 TC 换行断点只能发生在 clusters 边界。
|
||||
*/
|
||||
|
||||
export type GraphemeSegmentationMode = 'PREFERRED' | 'FALLBACK';
|
||||
|
||||
export type GraphemeSegmentationStrategy = 'INTL_SEGMENTER' | 'FALLBACK';
|
||||
|
||||
export type GraphemeSegmentationMeta = {
|
||||
strategy: GraphemeSegmentationStrategy;
|
||||
hadFallback: boolean;
|
||||
};
|
||||
|
||||
export type GraphemeSegmentationResult = {
|
||||
clusters: string[];
|
||||
meta: GraphemeSegmentationMeta;
|
||||
};
|
||||
|
||||
4
client/src/features/textWrap/index.ts
Normal file
4
client/src/features/textWrap/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type { WrapTextConstraints, WrapTextInput, WrapTextMeta, WrapTextOutput } from './types';
|
||||
|
||||
export { wrapText } from './wrapText';
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MeasureWidthImpl } from '../types';
|
||||
import { MissingFontSpecError } from '../errors';
|
||||
import { buildFontSpecKey } from '../fontSpecKey';
|
||||
import { measureSliceWidthCached } from '../measureSliceWidthCached';
|
||||
import { measureWidthCached } from '../measureWidthCached';
|
||||
|
||||
describe('textWrap width-measurement', () => {
|
||||
it('buildFontSpecKey: 缺字段必须报错(简体中文)', () => {
|
||||
expect(() => buildFontSpecKey({ fontFamily: 'PingFangSC-Regular', fontWeight: '400' } as any)).toThrow(
|
||||
MissingFontSpecError
|
||||
);
|
||||
});
|
||||
|
||||
it('measureWidthCached: 未提供测量能力 -> approx + WIDTH_UNKNOWN', async () => {
|
||||
const res = await measureWidthCached({
|
||||
text: 'hello',
|
||||
context: 'WIDGET',
|
||||
contextProfile: 'WIDGET',
|
||||
fontSpec: null,
|
||||
measureWidthImpl: undefined,
|
||||
});
|
||||
expect(res.width).toBeNull();
|
||||
expect(res.meta).toEqual({ isApprox: true, reason: 'WIDTH_UNKNOWN' });
|
||||
});
|
||||
|
||||
it('measureWidthCached: 同 key 命中缓存(不会重复调用底层测量)', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
|
||||
const a = await measureWidthCached({
|
||||
text: 'abc',
|
||||
context: 'APP',
|
||||
contextProfile: 'APP|ios',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
});
|
||||
const b = await measureWidthCached({
|
||||
text: 'abc',
|
||||
context: 'APP',
|
||||
contextProfile: 'APP|ios',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
});
|
||||
|
||||
expect(a.width).toBe(3);
|
||||
expect(b.width).toBe(3);
|
||||
expect(impl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('measureWidthCached: 测量抛错 -> approx + MEASURE_FAILED', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const res = await measureWidthCached({
|
||||
text: 'abc',
|
||||
context: 'APP',
|
||||
contextProfile: 'APP|ios|throw',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
});
|
||||
|
||||
expect(res.width).toBeNull();
|
||||
expect(res.meta).toEqual({ isApprox: true, reason: 'MEASURE_FAILED' });
|
||||
});
|
||||
|
||||
it('measureSliceWidthCached: 同 sliceKey 命中缓存', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
|
||||
const tokens = [
|
||||
{ text: 'I', start: 0, end: 1 },
|
||||
{ text: 'am', start: 2, end: 4 },
|
||||
{ text: 'tired', start: 5, end: 10 },
|
||||
];
|
||||
|
||||
const a = await measureSliceWidthCached({
|
||||
tokens: tokens as any,
|
||||
start: 0,
|
||||
end: 2,
|
||||
context: 'APP',
|
||||
contextProfile: 'APP|ios',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
});
|
||||
const b = await measureSliceWidthCached({
|
||||
tokens: tokens as any,
|
||||
start: 0,
|
||||
end: 2,
|
||||
context: 'APP',
|
||||
contextProfile: 'APP|ios',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
});
|
||||
|
||||
expect(a.width).toBe('I am'.length);
|
||||
expect(b.width).toBe('I am'.length);
|
||||
expect(impl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
62
client/src/features/textWrap/measure/cache.ts
Normal file
62
client/src/features/textWrap/measure/cache.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Text Wrap - width-measurement 缓存
|
||||
*
|
||||
* 要求:
|
||||
* - 模块级常驻缓存(跨调用复用)
|
||||
* - 有容量上限(避免内存无限增长)
|
||||
* - Key 必须确定性(由上层拼接传入)
|
||||
*
|
||||
* 说明:
|
||||
* - 这里实现一个最小 LRU:Map 维护插入顺序;get 时“刷新”为最新。
|
||||
* - 缓存 value 允许是 Promise,以便并发请求去重(同 key 只测一次)。
|
||||
*/
|
||||
|
||||
export class LruCache<V> {
|
||||
private readonly maxSize: number;
|
||||
private readonly map: Map<string, V>;
|
||||
|
||||
constructor(maxSize: number) {
|
||||
if (!Number.isFinite(maxSize) || maxSize <= 0) {
|
||||
throw new Error('LRU 缓存 maxSize 必须为正数。');
|
||||
}
|
||||
this.maxSize = Math.floor(maxSize);
|
||||
this.map = new Map();
|
||||
}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const v = this.map.get(key);
|
||||
if (v === undefined) return undefined;
|
||||
// 刷新为最新
|
||||
this.map.delete(key);
|
||||
this.map.set(key, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
set(key: string, value: V): void {
|
||||
if (this.map.has(key)) {
|
||||
this.map.delete(key);
|
||||
}
|
||||
this.map.set(key, value);
|
||||
|
||||
if (this.map.size > this.maxSize) {
|
||||
// 淘汰最旧的
|
||||
const oldestKey = this.map.keys().next().value as string | undefined;
|
||||
if (oldestKey !== undefined) {
|
||||
this.map.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
}
|
||||
|
||||
19
client/src/features/textWrap/measure/errors.ts
Normal file
19
client/src/features/textWrap/measure/errors.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Text Wrap - width-measurement 错误定义
|
||||
*
|
||||
* 约束:fontSpec 缺字段必须直接报错(简体中文),禁止隐式默认值。
|
||||
*/
|
||||
|
||||
export class MissingFontSpecError extends Error {
|
||||
readonly name = 'MissingFontSpecError';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMissingFontSpecMessage(missingFields: string[]): string {
|
||||
const fields = missingFields.join(', ');
|
||||
return `fontSpec 缺少必填字段:${fields}。请在调用 wrapText() 时补齐 fontSpec(fontFamily/fontWeight/fontSize),禁止在测量层使用隐式默认值。`;
|
||||
}
|
||||
|
||||
32
client/src/features/textWrap/measure/fontSpecKey.ts
Normal file
32
client/src/features/textWrap/measure/fontSpecKey.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { FontSpec } from './types';
|
||||
import { MissingFontSpecError, buildMissingFontSpecMessage } from './errors';
|
||||
|
||||
/**
|
||||
* 校验 fontSpec 必填字段。
|
||||
* 约束:缺失字段必须报错(简体中文),避免跨端漂移。
|
||||
*/
|
||||
export function assertFontSpecComplete(fontSpec: Partial<FontSpec> | undefined | null): asserts fontSpec is FontSpec {
|
||||
if (!fontSpec) {
|
||||
throw new MissingFontSpecError(buildMissingFontSpecMessage(['fontFamily', 'fontWeight', 'fontSize']));
|
||||
}
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!fontSpec.fontFamily) missing.push('fontFamily');
|
||||
if (!fontSpec.fontWeight) missing.push('fontWeight');
|
||||
if (fontSpec.fontSize === undefined || fontSpec.fontSize === null) missing.push('fontSize');
|
||||
|
||||
if (missing.length) throw new MissingFontSpecError(buildMissingFontSpecMessage(missing));
|
||||
if (!Number.isFinite(fontSpec.fontSize)) {
|
||||
throw new MissingFontSpecError('fontSpec.fontSize 必须是有限数值(number)。请检查调用 wrapText() 时传入的 fontSize。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 fontSpecKey(确定性)。
|
||||
* 规则:`fontFamily|fontWeight|fontSize`(顺序固定)
|
||||
*/
|
||||
export function buildFontSpecKey(fontSpec: Partial<FontSpec> | undefined | null): string {
|
||||
assertFontSpecComplete(fontSpec);
|
||||
return `${fontSpec.fontFamily}|${fontSpec.fontWeight}|${String(fontSpec.fontSize)}`;
|
||||
}
|
||||
|
||||
16
client/src/features/textWrap/measure/index.ts
Normal file
16
client/src/features/textWrap/measure/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type {
|
||||
ContextProfile,
|
||||
FontSpec,
|
||||
MeasureMeta,
|
||||
MeasureReason,
|
||||
MeasureResult,
|
||||
MeasureWidthImpl,
|
||||
TextWrapContext,
|
||||
} from './types';
|
||||
|
||||
export { MissingFontSpecError } from './errors';
|
||||
export { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||
export { defaultMeasureWidthImpl } from './measureWidthImpl';
|
||||
export { measureWidthCached } from './measureWidthCached';
|
||||
export { measureSliceWidthCached } from './measureSliceWidthCached';
|
||||
|
||||
105
client/src/features/textWrap/measure/measureSliceWidthCached.ts
Normal file
105
client/src/features/textWrap/measure/measureSliceWidthCached.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ContextProfile, FontSpec, MeasureResult, MeasureWidthImpl, TextWrapContext } from './types';
|
||||
import type { Token } from '../core/types';
|
||||
import { LruCache } from './cache';
|
||||
import { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||
import { joinTokens } from '../core/joinTokens';
|
||||
import { measureWidthCached } from './measureWidthCached';
|
||||
|
||||
// 切片缓存:同一 (start,end,fontSpecKey,contextProfile) 的宽度只测一次
|
||||
const sliceWidthCache = new LruCache<Promise<number>>(4000);
|
||||
|
||||
function fnv1a32(input: string): string {
|
||||
// 32-bit FNV-1a(确定性、实现简单;用于缓存 key 避免过长)
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
// hash *= 16777619(用位运算模拟 32-bit 溢出)
|
||||
hash = (hash + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function buildSliceKey(args: {
|
||||
contextProfile: ContextProfile;
|
||||
fontSpecKey: string;
|
||||
start: number;
|
||||
end: number;
|
||||
sliceTextHash: string;
|
||||
}): string {
|
||||
// 注意:必须包含 sliceText 的信息,否则不同文本的相同 (start,end) 会发生跨文本串缓存
|
||||
return `${args.contextProfile}|${args.fontSpecKey}|${args.start}|${args.end}|${args.sliceTextHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量 token 切片 `[start..end)` 的宽度(带缓存)。
|
||||
*
|
||||
* - 切片文本通过 `joinTokens()` 生成(保证与 core-contract 的重组口径一致)
|
||||
* - 内部复用 `measureWidthCached` 的降级语义
|
||||
*/
|
||||
export async function measureSliceWidthCached(args: {
|
||||
tokens: Token[];
|
||||
start: number;
|
||||
end: number;
|
||||
context: TextWrapContext;
|
||||
contextProfile: ContextProfile;
|
||||
fontSpec?: Partial<FontSpec> | null;
|
||||
measureWidthImpl?: MeasureWidthImpl;
|
||||
widgetEnableMeasure?: boolean;
|
||||
rawSeparators?: string[];
|
||||
}): Promise<MeasureResult> {
|
||||
const enabled =
|
||||
typeof args.measureWidthImpl === 'function' && (args.context === 'APP' || args.widgetEnableMeasure === true);
|
||||
|
||||
if (!enabled) {
|
||||
// 不启用测量时直接走 width=null(保持与 measureWidthCached 一致)
|
||||
return { width: null, meta: { isApprox: true, reason: 'WIDTH_UNKNOWN' } };
|
||||
}
|
||||
|
||||
assertFontSpecComplete(args.fontSpec);
|
||||
const fontSpecKey = buildFontSpecKey(args.fontSpec);
|
||||
const sliceText = joinTokens(args.tokens, args.start, args.end, args.rawSeparators);
|
||||
const key = buildSliceKey({
|
||||
contextProfile: args.contextProfile,
|
||||
fontSpecKey,
|
||||
start: args.start,
|
||||
end: args.end,
|
||||
sliceTextHash: fnv1a32(sliceText),
|
||||
});
|
||||
|
||||
const cached = sliceWidthCache.get(key);
|
||||
if (cached) {
|
||||
try {
|
||||
const w = await cached;
|
||||
return { width: w, meta: { isApprox: false } };
|
||||
} catch {
|
||||
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||
}
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const res = await measureWidthCached({
|
||||
text: sliceText,
|
||||
context: args.context,
|
||||
contextProfile: args.contextProfile,
|
||||
fontSpec: args.fontSpec,
|
||||
measureWidthImpl: args.measureWidthImpl,
|
||||
widgetEnableMeasure: args.widgetEnableMeasure,
|
||||
});
|
||||
|
||||
if (res.width === null) {
|
||||
throw new Error('切片测量失败或不可用。');
|
||||
}
|
||||
return res.width;
|
||||
})();
|
||||
|
||||
sliceWidthCache.set(key, promise);
|
||||
|
||||
try {
|
||||
const w = await promise;
|
||||
return { width: w, meta: { isApprox: false } };
|
||||
} catch {
|
||||
sliceWidthCache.delete(key);
|
||||
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||
}
|
||||
}
|
||||
|
||||
110
client/src/features/textWrap/measure/measureWidthCached.ts
Normal file
110
client/src/features/textWrap/measure/measureWidthCached.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { ContextProfile, FontSpec, MeasureResult, MeasureWidthImpl, TextWrapContext } from './types';
|
||||
import { LruCache } from './cache';
|
||||
import { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||
|
||||
// 模块级常驻缓存(两级)
|
||||
const textWidthCache = new LruCache<Promise<number>>(2000);
|
||||
const loggedMeasureFailures = new Set<string>();
|
||||
|
||||
function buildTextKey(args: { contextProfile: ContextProfile; fontSpecKey: string; text: string }): string {
|
||||
// key 拼接规则必须确定性:<contextProfile>|<fontSpecKey>|<text>
|
||||
return `${args.contextProfile}|${args.fontSpecKey}|${args.text}`;
|
||||
}
|
||||
|
||||
function isValidWidth(v: unknown): v is number {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量文本宽度(带缓存)。
|
||||
*
|
||||
* 约束:
|
||||
* - 若启用测量(提供 measureWidthImpl),fontSpec 必须完整;缺字段直接报错(简体中文)
|
||||
* - 若不启用测量(measureWidthImpl 缺失或 context=WIDGET 且明确不启用),进入 approx mode:width=null
|
||||
*/
|
||||
export async function measureWidthCached(args: {
|
||||
text: string;
|
||||
context: TextWrapContext;
|
||||
contextProfile: ContextProfile;
|
||||
fontSpec?: Partial<FontSpec> | null;
|
||||
measureWidthImpl?: MeasureWidthImpl;
|
||||
/** WIDGET 场景是否启用测量;默认 false(未启用即 WIDTH_UNKNOWN) */
|
||||
widgetEnableMeasure?: boolean;
|
||||
}): Promise<MeasureResult> {
|
||||
const text = String(args.text ?? '');
|
||||
|
||||
const enabled =
|
||||
typeof args.measureWidthImpl === 'function' && (args.context === 'APP' || args.widgetEnableMeasure === true);
|
||||
|
||||
if (!enabled) {
|
||||
return { width: null, meta: { isApprox: true, reason: 'WIDTH_UNKNOWN' } };
|
||||
}
|
||||
|
||||
// 启用测量时:fontSpec 缺字段必须报错
|
||||
const fontSpec = args.fontSpec;
|
||||
assertFontSpecComplete(fontSpec);
|
||||
const fontSpecKey = buildFontSpecKey(fontSpec);
|
||||
const key = buildTextKey({ contextProfile: args.contextProfile, fontSpecKey, text });
|
||||
|
||||
const cached = textWidthCache.get(key);
|
||||
if (cached) {
|
||||
try {
|
||||
const w = await cached;
|
||||
return isValidWidth(w) ? { width: w, meta: { isApprox: false } } : { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||
} catch {
|
||||
if (__DEV__ && !loggedMeasureFailures.has(key)) {
|
||||
loggedMeasureFailures.add(key);
|
||||
console.log('[TextWrap][Measure] measureWidthCached: 命中缓存但 Promise 失败(MEASURE_FAILED)', {
|
||||
context: args.context,
|
||||
contextProfile: args.contextProfile,
|
||||
fontSpec: args.fontSpec,
|
||||
textPreview: text.slice(0, 80),
|
||||
textLength: text.length,
|
||||
});
|
||||
}
|
||||
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||
}
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const w = await args.measureWidthImpl!({
|
||||
text,
|
||||
context: args.context,
|
||||
contextProfile: args.contextProfile,
|
||||
fontSpec,
|
||||
});
|
||||
if (!isValidWidth(w)) {
|
||||
throw new Error('测量结果非法(必须为有限且非负的 number)。');
|
||||
}
|
||||
return w;
|
||||
} catch (error) {
|
||||
if (__DEV__ && !loggedMeasureFailures.has(key)) {
|
||||
loggedMeasureFailures.add(key);
|
||||
console.log('[TextWrap][Measure] measureWidthImpl 抛错(MEASURE_FAILED)', {
|
||||
context: args.context,
|
||||
contextProfile: args.contextProfile,
|
||||
fontSpec,
|
||||
textPreview: text.slice(0, 80),
|
||||
textLength: text.length,
|
||||
errorName: (error as any)?.name,
|
||||
errorMessage: String((error as any)?.message ?? error),
|
||||
errorStack: (error as any)?.stack,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
textWidthCache.set(key, promise);
|
||||
|
||||
try {
|
||||
const w = await promise;
|
||||
return { width: w, meta: { isApprox: false } };
|
||||
} catch {
|
||||
// 若失败,删除缓存,避免缓存住失败结果
|
||||
textWidthCache.delete(key);
|
||||
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||
}
|
||||
}
|
||||
|
||||
65
client/src/features/textWrap/measure/measureWidthImpl.ts
Normal file
65
client/src/features/textWrap/measure/measureWidthImpl.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { FontSpec, MeasureWidthImpl } from './types';
|
||||
|
||||
/**
|
||||
* 默认测量实现(成熟方案):react-native-text-size
|
||||
*
|
||||
* 注意:
|
||||
* - 该库的 `measure` 是 Promise 异步接口,本模块以 async 方式对齐。
|
||||
* - 为避免在测试/Node 环境直接 require `react-native` 造成崩溃,这里使用“延迟加载 + 捕获异常”。
|
||||
*/
|
||||
|
||||
type TextSizeMeasureParams = {
|
||||
text: string;
|
||||
width?: number;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string;
|
||||
allowFontScaling?: boolean;
|
||||
usePreciseWidth?: boolean;
|
||||
};
|
||||
|
||||
type TextSizeMeasureResult = { width: number };
|
||||
|
||||
async function loadReactNativeTextSize(): Promise<{
|
||||
measure: (params: TextSizeMeasureParams) => Promise<TextSizeMeasureResult>;
|
||||
}> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mod: any = await import('react-native-text-size');
|
||||
return mod?.default ?? mod;
|
||||
}
|
||||
|
||||
function toTextSizeFontSpecs(fontSpec: FontSpec): Pick<TextSizeMeasureParams, 'fontFamily' | 'fontSize' | 'fontWeight'> {
|
||||
return {
|
||||
fontFamily: fontSpec.fontFamily,
|
||||
fontSize: fontSpec.fontSize,
|
||||
fontWeight: fontSpec.fontWeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认测量函数(可替换/可注入)。
|
||||
* - width 约束设为极大值,避免自动换行影响“单行宽度”测量
|
||||
* - usePreciseWidth=true,取更精确的宽度(开销更大,但对本算法更稳定)
|
||||
*/
|
||||
export const defaultMeasureWidthImpl: MeasureWidthImpl = async ({ text, fontSpec }) => {
|
||||
const TextSize = await loadReactNativeTextSize();
|
||||
if (!TextSize || typeof TextSize.measure !== 'function') {
|
||||
// 典型原因:在 Expo Go 中运行,或没有使用包含该原生模块的 Development Build。
|
||||
// 这里抛出更明确的错误,方便上层捕获并在 meta.reason=MEASURE_FAILED 时看到根因。
|
||||
throw new Error(
|
||||
[
|
||||
'react-native-text-size 原生模块不可用:TextSize.measure 不是函数。',
|
||||
'请确认你不是在 Expo Go 里运行;需要使用包含该原生模块的 Development Build(expo-dev-client / expo run:ios / EAS dev build)。',
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
const res = await TextSize.measure({
|
||||
text,
|
||||
width: 1_000_000_000,
|
||||
usePreciseWidth: true,
|
||||
allowFontScaling: true,
|
||||
...toTextSizeFontSpecs(fontSpec),
|
||||
});
|
||||
return res.width;
|
||||
};
|
||||
|
||||
48
client/src/features/textWrap/measure/types.ts
Normal file
48
client/src/features/textWrap/measure/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Text Wrap - width-measurement
|
||||
*
|
||||
* 本模块负责:
|
||||
* - 宽度测量(可注入实现)
|
||||
* - 两级缓存(文本/切片)
|
||||
* - 测量失败时的确定性降级(approx mode)
|
||||
*
|
||||
* 注意:真实测量(如 react-native-text-size)通常是异步 Promise,本模块统一使用 async 形式对齐实际能力。
|
||||
*/
|
||||
|
||||
export type TextWrapContext = 'APP' | 'WIDGET';
|
||||
|
||||
export type FontSpec = {
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用于区分不同测量语境(缓存隔离)。
|
||||
* 口径建议:`APP|<platform>|<scale?>` 或 `WIDGET|<widgetSize?>`
|
||||
*/
|
||||
export type ContextProfile = string;
|
||||
|
||||
export type MeasureReason = 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
|
||||
|
||||
export type MeasureMeta = {
|
||||
isApprox: boolean;
|
||||
reason?: MeasureReason;
|
||||
};
|
||||
|
||||
export type MeasureResult = {
|
||||
width: number | null;
|
||||
meta: MeasureMeta;
|
||||
};
|
||||
|
||||
/**
|
||||
* 可注入的测量实现(成熟方法推荐用原生/离屏测量库)。
|
||||
* 约定:返回值必须是“有限且非负”的 number,否则视为测量失败。
|
||||
*/
|
||||
export type MeasureWidthImpl = (args: {
|
||||
text: string;
|
||||
context: TextWrapContext;
|
||||
fontSpec: FontSpec;
|
||||
contextProfile: ContextProfile;
|
||||
}) => Promise<number>;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MeasureWidthImpl } from '../../measure/types';
|
||||
import { applyOverflowFallback } from '../fallback';
|
||||
|
||||
function t(text: string, start: number) {
|
||||
return { text, start, end: start + text.length };
|
||||
}
|
||||
|
||||
describe('textWrap overflow-fallback', () => {
|
||||
it('SYSTEM_DEFAULT:仍返回单行,但 meta 标记 SYSTEM_DEFAULT', async () => {
|
||||
const res = await applyOverflowFallback({
|
||||
tokens: [t('I', 0), t('am', 2), t('tired', 5)],
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 10,
|
||||
maxLines: 2,
|
||||
overflowMode: 'SYSTEM_DEFAULT',
|
||||
ellipsisToken: '…',
|
||||
reason: 'NO_CANDIDATE',
|
||||
partialLayout: null,
|
||||
});
|
||||
|
||||
expect(res.lines.length).toBe(1);
|
||||
expect(res.meta.fallback_type).toBe('SYSTEM_DEFAULT');
|
||||
expect(res.meta.overflow_type).toBe('NONE');
|
||||
});
|
||||
|
||||
it('CLIP:截断到 maxLines', async () => {
|
||||
const res = await applyOverflowFallback({
|
||||
tokens: [t('a', 0), t('b', 2), t('c', 4)],
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 10,
|
||||
maxLines: 2,
|
||||
overflowMode: 'CLIP',
|
||||
ellipsisToken: '…',
|
||||
reason: 'TOO_LONG',
|
||||
partialLayout: {
|
||||
breaks: [1, 2],
|
||||
lines: [
|
||||
{ start: 0, end: 1, text: 'a' },
|
||||
{ start: 1, end: 2, text: 'b' },
|
||||
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||
{ start: 2, end: 2, text: 'c' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.lines).toEqual(['a', 'b']);
|
||||
expect(res.meta.overflow_type).toBe('CLIP');
|
||||
});
|
||||
|
||||
it('ELLIPSIS:清理末尾空白与 TC 末尾标点(不输出 \" …\" / \",…\")', async () => {
|
||||
const res = await applyOverflowFallback({
|
||||
tokens: [t('我', 0), t('好', 1), t('累', 2), t(',', 3)],
|
||||
lang: 'TC',
|
||||
context: 'WIDGET',
|
||||
availableWidth: 10,
|
||||
maxLines: 1,
|
||||
overflowMode: 'ELLIPSIS',
|
||||
ellipsisToken: '…',
|
||||
reason: 'NO_CANDIDATE',
|
||||
partialLayout: {
|
||||
breaks: [],
|
||||
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||
lines: [{ start: 0, end: 3, text: '我好累, ' }],
|
||||
},
|
||||
tcPunctuations: [','],
|
||||
});
|
||||
|
||||
expect(res.lines[0]).toBe('我好累…');
|
||||
expect(res.meta.overflow_type).toBe('ELLIPSIS');
|
||||
});
|
||||
|
||||
it('ELLIPSIS:可测量时,超宽则按 token 回退直到不超宽', async () => {
|
||||
// mock:含省略号时宽度=100,否则宽度=文本长度
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => (text.includes('…') ? 100 : text.length));
|
||||
|
||||
const res = await applyOverflowFallback({
|
||||
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 10,
|
||||
maxLines: 1,
|
||||
overflowMode: 'ELLIPSIS',
|
||||
ellipsisToken: '…',
|
||||
reason: 'NO_CANDIDATE',
|
||||
partialLayout: {
|
||||
breaks: [],
|
||||
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||
lines: [{ start: 0, end: 3, text: 'I am so' }],
|
||||
},
|
||||
measure: {
|
||||
context: 'APP',
|
||||
contextProfile: 'APP|ios|ellipsis',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
},
|
||||
});
|
||||
|
||||
// 因为任何带 ellipsis 的测量都超宽,这里会一路回退到空串 + ellipsis
|
||||
expect(res.lines[0]).toBe('…');
|
||||
expect(res.meta.overflow_type).toBe('ELLIPSIS');
|
||||
});
|
||||
});
|
||||
|
||||
108
client/src/features/textWrap/overflow/ellipsis.ts
Normal file
108
client/src/features/textWrap/overflow/ellipsis.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Token } from '../core/types';
|
||||
import { joinTokens } from '../core/joinTokens';
|
||||
import { measureWidthCached } from '../measure/measureWidthCached';
|
||||
|
||||
import type { ApplyOverflowFallbackInput } from './types';
|
||||
|
||||
const DEFAULT_TC_PUNCTS = [',', '。', '!', '?', ';', ':', '、'];
|
||||
|
||||
export function cleanLineBeforeEllipsis(line: string, lang: 'TC' | 'EN', tcPunctuations?: string[]): string {
|
||||
const s = String(line ?? '');
|
||||
const trimmed = s.replace(/\s+$/g, '');
|
||||
if (trimmed === '') return '';
|
||||
|
||||
if (lang === 'TC') {
|
||||
const puncts = new Set(tcPunctuations && tcPunctuations.length > 0 ? tcPunctuations : DEFAULT_TC_PUNCTS);
|
||||
const last = trimmed.slice(-1);
|
||||
if (puncts.has(last)) return trimmed.slice(0, -1);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function measureIfPossible(args: {
|
||||
text: string;
|
||||
input: ApplyOverflowFallbackInput;
|
||||
}): Promise<number | null> {
|
||||
const { input, text } = args;
|
||||
if (!input.measure) return null;
|
||||
|
||||
const enabled =
|
||||
typeof input.measure.measureWidthImpl === 'function' &&
|
||||
(input.measure.context === 'APP' || input.measure.widgetEnableMeasure === true);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const res = await measureWidthCached({
|
||||
text,
|
||||
context: input.measure.context,
|
||||
contextProfile: input.measure.contextProfile,
|
||||
fontSpec: input.measure.fontSpec,
|
||||
measureWidthImpl: input.measure.measureWidthImpl,
|
||||
widgetEnableMeasure: input.measure.widgetEnableMeasure,
|
||||
});
|
||||
|
||||
return res.width;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给最后一行加省略号,并在可测量时保证不超宽:
|
||||
* - EN 回退单位:整词(token)
|
||||
* - TC 回退单位:grapheme(token)
|
||||
*/
|
||||
export async function applyEllipsisToLastLine(args: {
|
||||
input: ApplyOverflowFallbackInput;
|
||||
baseLines: Array<{ start: number; end: number; text: string }>;
|
||||
}): Promise<{ lines: string[] }> {
|
||||
const { input } = args;
|
||||
const maxLines = Math.max(1, input.maxLines | 0);
|
||||
const lines = args.baseLines.slice(0, maxLines);
|
||||
if (lines.length === 0) return { lines: [] };
|
||||
|
||||
const lastIdx = lines.length - 1;
|
||||
const last = lines[lastIdx]!;
|
||||
|
||||
// 先按规则清理,再拼接 ellipsisToken
|
||||
const cleaned = cleanLineBeforeEllipsis(last.text, input.lang, input.tcPunctuations);
|
||||
let candidateText = `${cleaned}${input.ellipsisToken}`;
|
||||
|
||||
// 若无测量能力:直接输出(确定性)
|
||||
const measured = await measureIfPossible({ text: candidateText, input });
|
||||
if (measured === null) {
|
||||
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||
return { lines: out };
|
||||
}
|
||||
|
||||
// 有测量能力:若超宽则按 token 回退
|
||||
if (measured <= input.availableWidth) {
|
||||
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||
return { lines: out };
|
||||
}
|
||||
|
||||
// 回退:逐步减少最后一行 token 数再加省略号
|
||||
let end = last.end;
|
||||
const start = last.start;
|
||||
let foundFit = false;
|
||||
while (end > start) {
|
||||
end -= 1;
|
||||
const base = joinTokens(input.tokens, start, end);
|
||||
const cleaned2 = cleanLineBeforeEllipsis(base, input.lang, input.tcPunctuations);
|
||||
const nextCandidate = `${cleaned2}${input.ellipsisToken}`;
|
||||
const w = await measureIfPossible({ text: nextCandidate, input });
|
||||
if (w !== null && w <= input.availableWidth) {
|
||||
candidateText = nextCandidate;
|
||||
foundFit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 若连“仅省略号”都无法满足宽度(极窄场景),也必须给出确定性输出:直接输出 ellipsisToken
|
||||
// 注意:这可能仍然超宽,但已是最小可表达形式;上层可通过 meta 做治理。
|
||||
if (!foundFit) {
|
||||
candidateText = input.ellipsisToken;
|
||||
}
|
||||
|
||||
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||
return { lines: out };
|
||||
}
|
||||
|
||||
86
client/src/features/textWrap/overflow/fallback.ts
Normal file
86
client/src/features/textWrap/overflow/fallback.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Token } from '../core/types';
|
||||
import { joinTokens } from '../core/joinTokens';
|
||||
|
||||
import { applyEllipsisToLastLine } from './ellipsis';
|
||||
import type { ApplyOverflowFallbackInput, ApplyOverflowFallbackResult, PartialLayoutInput, PartialLayoutLine } from './types';
|
||||
|
||||
function nTokens(tokens: Token[]): number {
|
||||
return Array.isArray(tokens) ? tokens.length : 0;
|
||||
}
|
||||
|
||||
function coversToEnd(partial: PartialLayoutInput | null | undefined, n: number): boolean {
|
||||
if (!partial || !Array.isArray(partial.lines) || partial.lines.length === 0) return false;
|
||||
const last = partial.lines[partial.lines.length - 1];
|
||||
return (last?.end ?? -1) === n;
|
||||
}
|
||||
|
||||
function buildLinesFromPartial(args: {
|
||||
tokens: Token[];
|
||||
partial?: PartialLayoutInput | null;
|
||||
maxLines: number;
|
||||
lang: 'TC' | 'EN';
|
||||
}): Array<{ start: number; end: number; text: string }> {
|
||||
const N = nTokens(args.tokens);
|
||||
const maxLines = Math.max(1, args.maxLines | 0);
|
||||
const rawSeparators = args.lang === 'TC' ? Array.from({ length: Math.max(0, N) }, () => '') : undefined;
|
||||
|
||||
if (args.partial && Array.isArray(args.partial.lines) && args.partial.lines.length > 0) {
|
||||
const out: Array<{ start: number; end: number; text: string }> = [];
|
||||
for (const line of args.partial.lines.slice(0, maxLines)) {
|
||||
const start = Math.max(0, line.start | 0);
|
||||
const end = Math.min(N, Math.max(start, line.end | 0));
|
||||
const text = line.text ?? joinTokens(args.tokens, start, end, rawSeparators);
|
||||
out.push({ start, end, text });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 若没有 partial:把全文当单行 best-effort
|
||||
return [{ start: 0, end: N, text: joinTokens(args.tokens, 0, N, rawSeparators) }];
|
||||
}
|
||||
|
||||
function toWrapped(lines: string[]): { lines: string[]; wrappedText: string } {
|
||||
const outLines = lines.filter((s) => s !== undefined) as string[];
|
||||
return { lines: outLines, wrappedText: outLines.join('\n') };
|
||||
}
|
||||
|
||||
function toSingleLine(tokens: Token[]): { lines: string[]; wrappedText: string } {
|
||||
const N = nTokens(tokens);
|
||||
// 注意:这里不知道语言口径,因此 SYSTEM_DEFAULT 的单行文本在上层保证传入“原文”更稳妥。
|
||||
// 为保持最小可用,这里仍使用默认 joinTokens(EN=空格 join,TC=grapheme 之间可能会有空格)。
|
||||
const text = joinTokens(tokens, 0, N);
|
||||
return { lines: [text], wrappedText: text };
|
||||
}
|
||||
|
||||
export async function applyOverflowFallback(input: ApplyOverflowFallbackInput): Promise<ApplyOverflowFallbackResult> {
|
||||
const tokens = input.tokens ?? [];
|
||||
const N = nTokens(tokens);
|
||||
const maxLines = Math.max(1, input.maxLines | 0);
|
||||
|
||||
const isOverflow = !coversToEnd(input.partialLayout, N);
|
||||
|
||||
// 若其实没有 overflow:直接返回(NONE)
|
||||
if (!isOverflow) {
|
||||
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||
const { lines, wrappedText } = toWrapped(base.map((l) => l.text));
|
||||
return { lines, wrappedText, meta: { fallback_type: 'NONE', overflow_type: 'NONE', reason: input.reason } };
|
||||
}
|
||||
|
||||
if (input.overflowMode === 'SYSTEM_DEFAULT') {
|
||||
const { lines, wrappedText } = toSingleLine(tokens);
|
||||
return { lines, wrappedText, meta: { fallback_type: 'SYSTEM_DEFAULT', overflow_type: 'NONE', reason: input.reason } };
|
||||
}
|
||||
|
||||
if (input.overflowMode === 'CLIP') {
|
||||
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||
const { lines, wrappedText } = toWrapped(base.slice(0, maxLines).map((l) => l.text));
|
||||
return { lines, wrappedText, meta: { fallback_type: 'NONE', overflow_type: 'CLIP', reason: input.reason } };
|
||||
}
|
||||
|
||||
// ELLIPSIS
|
||||
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||
const { lines } = await applyEllipsisToLastLine({ input, baseLines: base.slice(0, maxLines) });
|
||||
const wrapped = toWrapped(lines);
|
||||
return { ...wrapped, meta: { fallback_type: 'NONE', overflow_type: 'ELLIPSIS', reason: input.reason } };
|
||||
}
|
||||
|
||||
15
client/src/features/textWrap/overflow/index.ts
Normal file
15
client/src/features/textWrap/overflow/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
ApplyOverflowFallbackInput,
|
||||
ApplyOverflowFallbackResult,
|
||||
FallbackType,
|
||||
OverflowMeasure,
|
||||
OverflowMode,
|
||||
OverflowReason,
|
||||
OverflowType,
|
||||
PartialLayoutInput,
|
||||
PartialLayoutLine,
|
||||
WrapTextMeta,
|
||||
} from './types';
|
||||
|
||||
export { applyOverflowFallback } from './fallback';
|
||||
|
||||
47
client/src/features/textWrap/overflow/types.ts
Normal file
47
client/src/features/textWrap/overflow/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Token } from '../core/types';
|
||||
import type { ContextProfile, FontSpec, MeasureWidthImpl, TextWrapContext } from '../measure/types';
|
||||
|
||||
export type OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT';
|
||||
export type FallbackType = 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT';
|
||||
export type OverflowType = 'NONE' | 'ELLIPSIS' | 'CLIP';
|
||||
|
||||
export type OverflowReason = 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'TOO_LONG' | 'WIDOW' | 'PARTICLE' | string;
|
||||
|
||||
export type PartialLayoutLine = { start: number; end: number; text?: string };
|
||||
export type PartialLayoutInput = { breaks: number[]; lines: PartialLayoutLine[] };
|
||||
|
||||
export type OverflowMeasure = {
|
||||
context: TextWrapContext;
|
||||
contextProfile: ContextProfile;
|
||||
fontSpec: Partial<FontSpec> | null | undefined;
|
||||
measureWidthImpl?: MeasureWidthImpl;
|
||||
widgetEnableMeasure?: boolean;
|
||||
};
|
||||
|
||||
export type ApplyOverflowFallbackInput = {
|
||||
tokens: Token[];
|
||||
lang: 'TC' | 'EN';
|
||||
context: TextWrapContext;
|
||||
availableWidth: number;
|
||||
maxLines: number;
|
||||
overflowMode: OverflowMode;
|
||||
ellipsisToken: string;
|
||||
reason: OverflowReason;
|
||||
partialLayout?: PartialLayoutInput | null;
|
||||
measure?: OverflowMeasure;
|
||||
/** TC 标点集合(用于清理 `,…`) */
|
||||
tcPunctuations?: string[];
|
||||
};
|
||||
|
||||
export type WrapTextMeta = {
|
||||
fallback_type: FallbackType;
|
||||
overflow_type: OverflowType;
|
||||
reason: OverflowReason;
|
||||
};
|
||||
|
||||
export type ApplyOverflowFallbackResult = {
|
||||
lines: string[];
|
||||
wrappedText: string;
|
||||
meta: WrapTextMeta;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Token } from '../../core/types';
|
||||
import { buildTieKey, DEFAULT_LEXICONS, DEFAULT_WEIGHTS, scoreLayout } from '../index';
|
||||
|
||||
function t(text: string, start: number): Token {
|
||||
return { text, start, end: start + text.length };
|
||||
}
|
||||
|
||||
function lexicographicLess(a: Array<number | string>, b: Array<number | string>): boolean {
|
||||
const n = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const av = a[i] as any;
|
||||
const bv = b[i] as any;
|
||||
if (av < bv) return true;
|
||||
if (av > bv) return false;
|
||||
}
|
||||
return a.length < b.length;
|
||||
}
|
||||
|
||||
describe('textWrap scoring-tiebreak', () => {
|
||||
it('EMOTION_SPLIT:拆分情绪短语 -> 强惩罚 + 追加 spanLen(方案 A)', () => {
|
||||
const tokens: Token[] = [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)];
|
||||
const layoutCandidate = {
|
||||
breaks: [3], // I am so | tired
|
||||
lines: [
|
||||
{ start: 0, end: 3, text: 'I am so', width: 10, tokenCount: 3, charCount: 0 },
|
||||
{ start: 3, end: 4, text: 'tired', width: 10, tokenCount: 1, charCount: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
const res = scoreLayout({
|
||||
tokens,
|
||||
layoutCandidate,
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 100,
|
||||
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||
lexicons: { ...DEFAULT_LEXICONS, emotionPhrasesEN: ['so tired'] },
|
||||
debug: true,
|
||||
});
|
||||
|
||||
// 触发强惩罚:-(P_EMOTION_SPLIT + spanLen)
|
||||
const first = res.scoreBreakdown?.terms[0];
|
||||
expect(first?.key).toBe('EMOTION_SPLIT');
|
||||
expect(first?.delta).toBe(-(DEFAULT_WEIGHTS.P_EMOTION_SPLIT + 2));
|
||||
expect(res.flags.emotionSplit).toBe(true);
|
||||
});
|
||||
|
||||
it('Shift/Accum/Self:EN 词两端带标点也应命中(全词等值匹配)', () => {
|
||||
const tokens: Token[] = [t('but,', 0), t('still', 5), t('ok', 11)];
|
||||
const layoutCandidate = {
|
||||
breaks: [1], // but, | still ok
|
||||
lines: [
|
||||
// 把 width 设置到 idealWidth 上,避免 length 项干扰 debug Top-3
|
||||
{ start: 0, end: 1, text: 'but,', width: 90, tokenCount: 1, charCount: 0 },
|
||||
{ start: 1, end: 3, text: 'still ok', width: 90, tokenCount: 2, charCount: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
const res = scoreLayout({
|
||||
tokens,
|
||||
layoutCandidate,
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 100, // idealWidth=100*0.9=90
|
||||
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
debug: true,
|
||||
});
|
||||
|
||||
const terms = res.scoreBreakdown?.terms ?? [];
|
||||
expect(terms.some((x) => x.key === 'SHIFT_BREAK')).toBe(true);
|
||||
expect(terms.some((x) => x.key === 'ACCUM_BREAK')).toBe(true);
|
||||
});
|
||||
|
||||
it('10.2G:EmotionWord 与 Accum 同时命中 -> Accum 奖励减半(整数)', () => {
|
||||
const tokens: Token[] = [t('already', 0), t('tired', 8)];
|
||||
const layoutCandidate = {
|
||||
breaks: [1], // already | tired
|
||||
lines: [
|
||||
{ start: 0, end: 1, text: 'already', width: 90, tokenCount: 1, charCount: 0 },
|
||||
{ start: 1, end: 2, text: 'tired', width: 90, tokenCount: 1, charCount: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
const weights = { ...DEFAULT_WEIGHTS, R_EMOTION_TAIL: 10, R_ACCUM_BREAK: 41 }; // 41/2 -> 20(floor)
|
||||
|
||||
const res = scoreLayout({
|
||||
tokens,
|
||||
layoutCandidate,
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
availableWidth: 100,
|
||||
config: { weights, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
debug: true,
|
||||
});
|
||||
|
||||
const terms = res.scoreBreakdown?.terms ?? [];
|
||||
// debug 可能截断为 Top-3,但应包含 EMOTION_TAIL 与 ACCUM_BREAK(顺序确定性)
|
||||
const hasEmotion = terms.some((x) => x.key === 'EMOTION_TAIL' && x.delta === 10);
|
||||
const hasAccumHalf = terms.some((x) => x.key === 'ACCUM_BREAK' && x.delta === Math.floor(41 / 2));
|
||||
expect(hasEmotion).toBe(true);
|
||||
expect(hasAccumHalf).toBe(true);
|
||||
});
|
||||
|
||||
it('tieKey:lastLineWidth 更大优先(取 -lastLineWidth)', () => {
|
||||
const base = {
|
||||
breaks: [2],
|
||||
lines: [
|
||||
{ start: 0, end: 2, text: 'a b', width: 50, tokenCount: 2, charCount: 0 },
|
||||
{ start: 2, end: 3, text: 'c', width: 10, tokenCount: 1, charCount: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
const scoredA = { score: 0, flags: {}, tieKey: [] as any };
|
||||
const scoredB = { score: 0, flags: {}, tieKey: [] as any };
|
||||
|
||||
const keyShort = buildTieKey({
|
||||
scoredLayout: scoredA as any,
|
||||
layoutCandidate: base as any,
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
tokenCount: 3,
|
||||
availableWidth: 100,
|
||||
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||
});
|
||||
|
||||
const layoutLongTail = {
|
||||
...base,
|
||||
lines: [
|
||||
base.lines[0]!,
|
||||
{ ...base.lines[1]!, width: 30 }, // lastLineWidth 更大
|
||||
],
|
||||
};
|
||||
const keyLong = buildTieKey({
|
||||
scoredLayout: scoredB as any,
|
||||
layoutCandidate: layoutLongTail as any,
|
||||
lang: 'EN',
|
||||
context: 'APP',
|
||||
tokenCount: 3,
|
||||
availableWidth: 100,
|
||||
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||
});
|
||||
|
||||
// 更优的 tieKey 应“更小”(因为 lastLineWidth 取负)
|
||||
expect(lexicographicLess(keyLong, keyShort)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
21
client/src/features/textWrap/scoring/index.ts
Normal file
21
client/src/features/textWrap/scoring/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type {
|
||||
LayoutCandidate,
|
||||
LayoutCandidateLine,
|
||||
Lexicons,
|
||||
ScoredLayout,
|
||||
ScoreBreakdown,
|
||||
ScoreLayoutInput,
|
||||
ScoreTerm,
|
||||
ScoringConfig,
|
||||
TextWrapContext,
|
||||
Weights,
|
||||
} from './types';
|
||||
|
||||
export { DEFAULT_LEXICONS, mergeLexicons } from './lexicons';
|
||||
export { DEFAULT_WEIGHTS, mergeWeights } from './weights';
|
||||
|
||||
export { preparePhraseTokens, findPhraseSpans, isSpanSplitByBreaks } from './phraseMatch';
|
||||
export { scoreLayout } from './score';
|
||||
export type { BuildTieKeyInput } from './tieKey';
|
||||
export { buildTieKey } from './tieKey';
|
||||
|
||||
31
client/src/features/textWrap/scoring/lexicons.ts
Normal file
31
client/src/features/textWrap/scoring/lexicons.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Lexicons } from './types';
|
||||
|
||||
/**
|
||||
* scoring-tiebreak 最小词表(首版写死客户端)
|
||||
*
|
||||
* 来源:`设计说明文档/文档换行算法.md v1.2.1` 第 7 节
|
||||
* 原则:少而准(后续通过打点迭代扩充)
|
||||
*/
|
||||
export const DEFAULT_LEXICONS: Readonly<Lexicons> = Object.freeze({
|
||||
emotionPhrasesTC: [],
|
||||
emotionPhrasesEN: [],
|
||||
protectedPhrases: [],
|
||||
|
||||
shiftWordsTC: ['但', '可是', '然而', '却', '只是', '偏偏'],
|
||||
shiftWordsEN: ['but', 'yet', 'so'],
|
||||
|
||||
accumWordsTC: ['已经', '一直', '曾经', '终于', '还是', '到现在'],
|
||||
accumWordsEN: ['already', 'still', 'even', 'just', 'really'],
|
||||
|
||||
selfWordsTC: ['你', '我', '自己', '我们', '别人'],
|
||||
selfWordsEN: ['you', 'yourself', 'me', 'we'],
|
||||
|
||||
emotionWordsTC: ['累', '痛', '怕', '孤单', '委屈', '撑', '崩溃', '放弃'],
|
||||
emotionWordsEN: ['tired', 'afraid', 'lonely', 'hurt', 'overwhelmed', 'give up'],
|
||||
});
|
||||
|
||||
export function mergeLexicons(overrides?: Partial<Lexicons> | null | undefined): Lexicons {
|
||||
if (!overrides) return { ...DEFAULT_LEXICONS };
|
||||
return { ...DEFAULT_LEXICONS, ...overrides };
|
||||
}
|
||||
|
||||
92
client/src/features/textWrap/scoring/phraseMatch.ts
Normal file
92
client/src/features/textWrap/scoring/phraseMatch.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Lang, Token } from '../core/types';
|
||||
import { matchENKeyword, normalizeENKeyword, normalizeWhitespace, tokenizeEN } from '../core/index';
|
||||
import { segmentGraphemes } from '../grapheme/index';
|
||||
|
||||
export type PhraseSpan = { start: number; end: number; length: number };
|
||||
|
||||
function toNonEmptyArray(parts: string[]): string[] {
|
||||
return parts.map((s) => s.trim()).filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
export function preparePhraseTokens(phrase: string, lang: Lang): string[] {
|
||||
const input = String(phrase ?? '');
|
||||
if (input === '') return [];
|
||||
|
||||
if (lang === 'EN') {
|
||||
const { normalizedText } = normalizeWhitespace(input, 'NORMALIZE');
|
||||
const words = normalizedText.split(' ');
|
||||
// EN:按“全词等值匹配”口径做归一化(小写 + 两端去常见标点)
|
||||
return toNonEmptyArray(words).map((w) => normalizeENKeyword(w));
|
||||
}
|
||||
|
||||
// TC:按 grapheme clusters
|
||||
const { clusters } = segmentGraphemes(input, 'PREFERRED');
|
||||
return clusters;
|
||||
}
|
||||
|
||||
function tokenEqualsPhraseToken(tokenText: string, phraseToken: string, lang: Lang): boolean {
|
||||
if (lang === 'EN') return matchENKeyword(tokenText, phraseToken);
|
||||
return tokenText === phraseToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 tokens 中寻找 phraseTokens 的“连续区间完全匹配”(必须)。
|
||||
*
|
||||
* 输出顺序(确定性):
|
||||
* - start 升序
|
||||
* - start 相同:length 降序(更长优先)
|
||||
*/
|
||||
export function findPhraseSpans(tokens: Token[], phraseTokens: string[], lang: Lang): PhraseSpan[] {
|
||||
const n = tokens.length;
|
||||
const m = phraseTokens.length;
|
||||
if (n === 0 || m === 0 || m > n) return [];
|
||||
|
||||
const spans: PhraseSpan[] = [];
|
||||
|
||||
for (let i = 0; i <= n - m; i++) {
|
||||
let ok = true;
|
||||
for (let j = 0; j < m; j++) {
|
||||
const t = tokens[i + j]?.text ?? '';
|
||||
const p = phraseTokens[j] ?? '';
|
||||
if (!tokenEqualsPhraseToken(t, p, lang)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) spans.push({ start: i, end: i + m, length: m });
|
||||
}
|
||||
|
||||
spans.sort((a, b) => {
|
||||
if (a.start !== b.start) return a.start - b.start;
|
||||
return b.length - a.length;
|
||||
});
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个 span 是否被断点拆分到不同的行(即:span 内存在 break)。
|
||||
*
|
||||
* 说明:
|
||||
* - span=[start,end) 是 token 索引区间
|
||||
* - breaks[i] 是 token 边界索引(1..N-1)
|
||||
* - 若存在 start < b < end,则 span 被拆分
|
||||
*/
|
||||
export function isSpanSplitByBreaks(span: PhraseSpan, breaks: number[]): boolean {
|
||||
const s = span.start;
|
||||
const e = span.end;
|
||||
if (e - s <= 1) return false;
|
||||
for (const b of breaks) {
|
||||
if (b > s && b < e) return true;
|
||||
if (b >= e) break; // breaks 升序:可提前退出
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅用于测试:把 EN 文本按 core-contract 口径 tokenize。
|
||||
*/
|
||||
export function tokenizePhraseLikeInputEN(text: string): Token[] {
|
||||
const { normalizedText } = normalizeWhitespace(text, 'NORMALIZE');
|
||||
return tokenizeEN(normalizedText);
|
||||
}
|
||||
|
||||
367
client/src/features/textWrap/scoring/score.ts
Normal file
367
client/src/features/textWrap/scoring/score.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import type { Lang, Token } from '../core/types';
|
||||
import { normalizeENKeyword } from '../core/index';
|
||||
|
||||
import type { LayoutCandidateLine, ScoreLayoutInput, ScoreTerm, ScoredLayout, Weights } from './types';
|
||||
import { findPhraseSpans, isSpanSplitByBreaks, preparePhraseTokens } from './phraseMatch';
|
||||
import { mergeLexicons } from './lexicons';
|
||||
import { mergeWeights } from './weights';
|
||||
|
||||
function asInt(n: number): number {
|
||||
// 保证输出整数(避免后续误用浮点)
|
||||
return n | 0;
|
||||
}
|
||||
|
||||
function absInt(n: number): number {
|
||||
return n < 0 ? -n : n;
|
||||
}
|
||||
|
||||
function roundToInt(n: number): number {
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
function ratioToBp(ratio: number): number {
|
||||
// 把 0.90 转为 900(bp=1/1000),用于尽量规避浮点带来的跨端差异
|
||||
return Math.round(ratio * 1000);
|
||||
}
|
||||
|
||||
function lineWidthOrApprox(line: LayoutCandidateLine, lang: Lang): number {
|
||||
if (typeof line.width === 'number' && Number.isFinite(line.width)) return roundToInt(line.width);
|
||||
// width unknown/approx:回退到 tokenCount / charCount(确定性)
|
||||
return lang === 'EN' ? asInt(line.tokenCount) : asInt(line.charCount);
|
||||
}
|
||||
|
||||
function buildSet(arr: string[] | undefined, lang: Lang): Set<string> {
|
||||
if (!arr || arr.length === 0) return new Set();
|
||||
if (lang === 'EN') return new Set(arr.map((w) => normalizeENKeyword(w)));
|
||||
return new Set(arr);
|
||||
}
|
||||
|
||||
function tokenHitSet(tokenText: string, set: Set<string>, lang: Lang): boolean {
|
||||
if (set.size === 0) return false;
|
||||
if (lang === 'EN') {
|
||||
// 全词等值匹配(允许两端标点包裹):normalize 后做等值命中即可
|
||||
return set.has(normalizeENKeyword(tokenText));
|
||||
}
|
||||
return set.has(tokenText);
|
||||
}
|
||||
|
||||
function isTcPunctuation(t: string, punctSet: Set<string>): boolean {
|
||||
return punctSet.has(t);
|
||||
}
|
||||
|
||||
function computeEmotionTailFlags(tokens: Token[], lines: LayoutCandidateLine[], lang: Lang, emotionWordsSet: Set<string>): boolean[] {
|
||||
const flags: boolean[] = [];
|
||||
if (emotionWordsSet.size === 0) return lines.map(() => false);
|
||||
|
||||
for (const line of lines) {
|
||||
const start = Math.max(0, line.start | 0);
|
||||
const end = Math.max(start, line.end | 0);
|
||||
const slice = tokens.slice(start, end);
|
||||
|
||||
if (slice.length === 0) {
|
||||
flags.push(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lang === 'EN') {
|
||||
const last = slice[slice.length - 1]?.text ?? '';
|
||||
flags.push(tokenHitSet(last, emotionWordsSet, 'EN'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// TC:“最后 2 个 grapheme 范围内命中”
|
||||
const last1 = slice[slice.length - 1]?.text ?? '';
|
||||
const last2 = slice.length >= 2 ? slice[slice.length - 2]?.text ?? '' : '';
|
||||
flags.push(tokenHitSet(last1, emotionWordsSet, 'TC') || tokenHitSet(last2, emotionWordsSet, 'TC'));
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
function limitBreakdownForDebug(terms: ScoreTerm[]): ScoreTerm[] {
|
||||
// 裁决补充 10.4A:debug Top-3 按规则优先级(10.1 顺序)输出
|
||||
// 这里采用最稳妥的实现:直接取“按实现顺序生成的前 3 项”(确定性且满足优先级排序)
|
||||
return terms.slice(0, 3);
|
||||
}
|
||||
|
||||
export function scoreLayout(input: ScoreLayoutInput): ScoredLayout {
|
||||
const weights: Weights = mergeWeights(input.config.weights);
|
||||
const lexicons = mergeLexicons(input.lexicons);
|
||||
|
||||
const tokens = input.tokens ?? [];
|
||||
const breaks = (input.layoutCandidate.breaks ?? []).slice().sort((a, b) => a - b);
|
||||
const lines = input.layoutCandidate.lines ?? [];
|
||||
|
||||
const terms: ScoreTerm[] = [];
|
||||
let score = 0;
|
||||
|
||||
const flags: ScoredLayout['flags'] = {
|
||||
overflowed: Boolean(input.layoutCandidate.meta?.overflowed),
|
||||
fallback: Boolean(input.layoutCandidate.meta?.fallback),
|
||||
};
|
||||
|
||||
// ------------------------------
|
||||
// 10.1-1 情绪短语拆分惩罚(最高优先)
|
||||
// ------------------------------
|
||||
{
|
||||
let deltaEmotion = 0;
|
||||
let deltaProtected = 0;
|
||||
|
||||
const listEmotion = input.lang === 'EN' ? lexicons.emotionPhrasesEN : lexicons.emotionPhrasesTC;
|
||||
const listProtected = lexicons.protectedPhrases ?? [];
|
||||
|
||||
const pushSplit = (key: 'EMOTION_SPLIT' | 'PROTECTED_SPLIT', phrase: string, spanLen: number) => {
|
||||
const base = key === 'EMOTION_SPLIT' ? weights.P_EMOTION_SPLIT : weights.P_PROTECTED_SPLIT;
|
||||
const d = -asInt(base) - asInt(spanLen); // 方案 A:追加“被拆分短语长度”惩罚(更长短语更强保护)
|
||||
if (key === 'EMOTION_SPLIT') deltaEmotion += d;
|
||||
else deltaProtected += d;
|
||||
|
||||
flags.emotionSplit = true;
|
||||
// 记录 detail 便于治理
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
terms.push({ key, delta: d, detail: { phrase, spanLen } });
|
||||
};
|
||||
|
||||
// emotion phrases
|
||||
for (const phrase of listEmotion) {
|
||||
const phraseTokens = preparePhraseTokens(phrase, input.lang);
|
||||
const spans = findPhraseSpans(tokens, phraseTokens, input.lang);
|
||||
for (const span of spans) {
|
||||
if (isSpanSplitByBreaks(span, breaks)) pushSplit('EMOTION_SPLIT', phrase, span.length);
|
||||
}
|
||||
}
|
||||
|
||||
// protected phrases
|
||||
for (const phrase of listProtected) {
|
||||
const phraseTokens = preparePhraseTokens(phrase, input.lang);
|
||||
const spans = findPhraseSpans(tokens, phraseTokens, input.lang);
|
||||
for (const span of spans) {
|
||||
if (isSpanSplitByBreaks(span, breaks)) pushSplit('PROTECTED_SPLIT', phrase, span.length);
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:这里已经把 term 推入了 terms(保持“优先级最高的项”最先出现)
|
||||
score += deltaEmotion + deltaProtected;
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// 10.1-2 行超长惩罚 + 理想长度奖励
|
||||
// ------------------------------
|
||||
{
|
||||
const idealBp = ratioToBp(input.config.idealWidthRatio[input.context]);
|
||||
const idealWidth = roundToInt((input.availableWidth * idealBp) / 1000);
|
||||
const minPreferredRatio = input.config.minPreferredRatio ?? 0.6;
|
||||
const minPreferredWidth = roundToInt((idealWidth * ratioToBp(minPreferredRatio)) / 1000);
|
||||
|
||||
let overMaxDelta = 0;
|
||||
let idealDelta = 0;
|
||||
let tooShortDelta = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const w = lineWidthOrApprox(line, input.lang);
|
||||
|
||||
if (w > input.availableWidth) {
|
||||
overMaxDelta -= asInt(weights.P_OVER_MAXLEN) * asInt(w - input.availableWidth);
|
||||
}
|
||||
|
||||
idealDelta -= absInt(w - idealWidth);
|
||||
|
||||
if (w < minPreferredWidth) {
|
||||
tooShortDelta -= asInt(weights.P_TOO_SHORT) * asInt(minPreferredWidth - w);
|
||||
}
|
||||
}
|
||||
|
||||
if (overMaxDelta !== 0) terms.push({ key: 'OVER_MAXLEN', delta: overMaxDelta, detail: { availableWidth: input.availableWidth } });
|
||||
if (idealDelta !== 0) terms.push({ key: 'IDEAL_LEN', delta: idealDelta, detail: { idealWidth, idealBp } });
|
||||
if (tooShortDelta !== 0) terms.push({ key: 'TOO_SHORT', delta: tooShortDelta, detail: { minPreferredWidth } });
|
||||
|
||||
score += overMaxDelta + idealDelta + tooShortDelta;
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// 10.1-3 widow(EN)/ 单字行(TC)
|
||||
// ------------------------------
|
||||
{
|
||||
const lastLine = lines[lines.length - 1];
|
||||
if (lastLine) {
|
||||
const lastWidth = lineWidthOrApprox(lastLine, input.lang);
|
||||
|
||||
if (input.lang === 'EN') {
|
||||
const tokenCount = asInt(lastLine.tokenCount);
|
||||
if (tokenCount === 1) {
|
||||
const d = -asInt(weights.P_WIDOW_LINE);
|
||||
terms.push({ key: 'WIDOW_LINE', delta: d });
|
||||
score += d;
|
||||
|
||||
const start = Math.max(0, lastLine.start | 0);
|
||||
const end = Math.max(start, lastLine.end | 0);
|
||||
const only = tokens.slice(start, end)[0]?.text ?? '';
|
||||
const norm = normalizeENKeyword(only);
|
||||
const widowMaxLen = asInt(input.config.widowMaxLen ?? 3);
|
||||
if (norm.length > 0 && norm.length <= widowMaxLen) {
|
||||
const d2 = -asInt(weights.P_WIDOW_WORD);
|
||||
terms.push({ key: 'WIDOW_WORD', delta: d2, detail: { word: norm, widowMaxLen } });
|
||||
score += d2;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TC:最后一行只有 1 个 grapheme(或 charCount<=1)视为“单字行”
|
||||
const c = asInt(lastLine.charCount);
|
||||
if (c <= 1) {
|
||||
const d = -asInt(weights.P_WIDOW_LINE);
|
||||
terms.push({ key: 'WIDOW_LINE', delta: d, detail: { tcSingleChar: true, lastWidth } });
|
||||
score += d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// 10.2F TC 助词孤立(在 10.1-3 后落地,保证顺序确定性)
|
||||
// ------------------------------
|
||||
if (input.lang === 'TC' && lines.length > 0) {
|
||||
const particles = new Set(input.config.tcParticles ?? ['啊', '喔', '呢', '啦', '吗', '吧', '呀']);
|
||||
const whitelist = new Set(input.config.tcParticleWhitelist ?? ['啊', '喔', '呢', '啦']);
|
||||
|
||||
let delta = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const start = Math.max(0, line.start | 0);
|
||||
const end = Math.max(start, line.end | 0);
|
||||
const slice = tokens.slice(start, end);
|
||||
if (slice.length === 0) continue;
|
||||
|
||||
const first = slice[0]?.text ?? '';
|
||||
const last = slice[slice.length - 1]?.text ?? '';
|
||||
|
||||
const hitFirst = particles.has(first);
|
||||
const hitLast = particles.has(last);
|
||||
if (!hitFirst && !hitLast) continue;
|
||||
|
||||
const base = asInt(weights.P_PARTICLE_ISO);
|
||||
const half = Math.floor(base / 2);
|
||||
|
||||
if (hitFirst) delta -= whitelist.has(first) ? half : base;
|
||||
if (hitLast) delta -= whitelist.has(last) ? half : base;
|
||||
}
|
||||
|
||||
if (delta !== 0) {
|
||||
terms.push({ key: 'PARTICLE_ISO', delta, detail: { particles: Array.from(particles), whitelist: Array.from(whitelist) } });
|
||||
score += delta;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// 10.1-4 标点断点奖励(TC)
|
||||
// ------------------------------
|
||||
if (input.lang === 'TC') {
|
||||
const puncts = new Set(input.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、']);
|
||||
let delta = 0;
|
||||
|
||||
for (const b of breaks) {
|
||||
const prev = tokens[b - 1]?.text ?? '';
|
||||
if (isTcPunctuation(prev, puncts)) delta += asInt(weights.R_PUNCT_BREAK);
|
||||
}
|
||||
|
||||
if (delta !== 0) {
|
||||
terms.push({ key: 'PUNCT_BREAK', delta, detail: { tcPunctuations: Array.from(puncts) } });
|
||||
score += delta;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// 10.1-5 Shift/Accum/Self 断点奖励(含 EN 行首/行尾感知)
|
||||
// ------------------------------
|
||||
{
|
||||
const shiftSet = buildSet(input.lang === 'EN' ? lexicons.shiftWordsEN : lexicons.shiftWordsTC, input.lang);
|
||||
const accumSet = buildSet(input.lang === 'EN' ? lexicons.accumWordsEN : lexicons.accumWordsTC, input.lang);
|
||||
const selfSet = buildSet(input.lang === 'EN' ? lexicons.selfWordsEN : lexicons.selfWordsTC, input.lang);
|
||||
const emotionSet = buildSet(input.lang === 'EN' ? lexicons.emotionWordsEN : lexicons.emotionWordsTC, input.lang);
|
||||
|
||||
const emotionTailByLine = computeEmotionTailFlags(tokens, lines, input.lang, emotionSet);
|
||||
|
||||
// EmotionWord 落点强化(默认权重为 0,不影响结果;但用于实现 10.2G 的“Accum 减半”规则)
|
||||
let emotionTailDelta = 0;
|
||||
for (let i = 0; i < emotionTailByLine.length; i++) {
|
||||
if (emotionTailByLine[i]) emotionTailDelta += asInt(weights.R_EMOTION_TAIL);
|
||||
}
|
||||
if (emotionTailDelta !== 0) {
|
||||
terms.push({ key: 'EMOTION_TAIL', delta: emotionTailDelta });
|
||||
score += emotionTailDelta;
|
||||
}
|
||||
|
||||
let shiftDelta = 0;
|
||||
let accumDelta = 0;
|
||||
let selfDelta = 0;
|
||||
|
||||
// 为 Accum 减半规则准备:把每个 break 映射到其左右行索引
|
||||
// 假设 lines 与 breaks 一一对应:breaks[i] 分隔 lines[i] 与 lines[i+1]
|
||||
for (let i = 0; i < breaks.length; i++) {
|
||||
const b = breaks[i]!;
|
||||
const prevLast = tokens[b - 1]?.text ?? '';
|
||||
const nextFirst = tokens[b]?.text ?? '';
|
||||
|
||||
const hitShift = tokenHitSet(nextFirst, shiftSet, input.lang) || tokenHitSet(prevLast, shiftSet, input.lang);
|
||||
const hitAccum = tokenHitSet(nextFirst, accumSet, input.lang) || tokenHitSet(prevLast, accumSet, input.lang);
|
||||
const hitSelf = tokenHitSet(nextFirst, selfSet, input.lang) || tokenHitSet(prevLast, selfSet, input.lang);
|
||||
|
||||
if (hitShift) shiftDelta += asInt(weights.R_SHIFT_BREAK);
|
||||
if (hitSelf) selfDelta += asInt(weights.R_SELF_BREAK);
|
||||
|
||||
if (hitAccum) {
|
||||
// 10.2G:若同一行(或同一断点)同时满足 EmotionWord 强化与 Accum 奖励 -> Accum 减半
|
||||
const leftLineIdx = i;
|
||||
const rightLineIdx = i + 1;
|
||||
const emotionHitNearby = Boolean(emotionTailByLine[leftLineIdx]) || Boolean(emotionTailByLine[rightLineIdx]);
|
||||
const base = asInt(weights.R_ACCUM_BREAK);
|
||||
const reward = emotionHitNearby ? Math.floor(base / 2) : base;
|
||||
accumDelta += reward;
|
||||
}
|
||||
}
|
||||
|
||||
if (shiftDelta !== 0) {
|
||||
terms.push({ key: 'SHIFT_BREAK', delta: shiftDelta });
|
||||
score += shiftDelta;
|
||||
}
|
||||
if (accumDelta !== 0) {
|
||||
terms.push({ key: 'ACCUM_BREAK', delta: accumDelta });
|
||||
score += accumDelta;
|
||||
}
|
||||
if (selfDelta !== 0) {
|
||||
terms.push({ key: 'SELF_BREAK', delta: selfDelta });
|
||||
score += selfDelta;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// 10.1-6 多行视觉均衡(尾行过短惩罚)
|
||||
// ------------------------------
|
||||
{
|
||||
const lastLine = lines[lines.length - 1];
|
||||
if (lastLine) {
|
||||
const idealBp = ratioToBp(input.config.idealWidthRatio[input.context]);
|
||||
const idealWidth = roundToInt((input.availableWidth * idealBp) / 1000);
|
||||
const shortRatio = input.config.shortLastLineRatio ?? 0.5;
|
||||
const shortThreshold = roundToInt((idealWidth * ratioToBp(shortRatio)) / 1000);
|
||||
|
||||
const lastW = lineWidthOrApprox(lastLine, input.lang);
|
||||
if (lastW < shortThreshold) {
|
||||
const d = -asInt(weights.P_SHORT_LASTLINE);
|
||||
terms.push({ key: 'SHORT_LASTLINE', delta: d, detail: { shortThreshold, lastW, idealWidth } });
|
||||
score += d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scoreBreakdown = input.debug ? { total: score, terms: limitBreakdownForDebug(terms) } : undefined;
|
||||
|
||||
// tieKey 在 tieKey.ts 单独构造(此处返回占位,调用方可覆盖)
|
||||
const tieKey: Array<number | string> = [
|
||||
flags.emotionSplit ? 1 : 0,
|
||||
flags.overflowed ? 1 : 0,
|
||||
// lastLineWidth/spread/idealDistance 等由 buildTieKey 计算;这里仅保证结构存在
|
||||
];
|
||||
|
||||
return { score, flags, tieKey, scoreBreakdown };
|
||||
}
|
||||
|
||||
96
client/src/features/textWrap/scoring/tieKey.ts
Normal file
96
client/src/features/textWrap/scoring/tieKey.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Lang } from '../core/types';
|
||||
|
||||
import type { LayoutCandidate, LayoutCandidateLine, ScoredLayout, TextWrapContext } from './types';
|
||||
|
||||
function asInt(n: number): number {
|
||||
return n | 0;
|
||||
}
|
||||
|
||||
function roundToInt(n: number): number {
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
function lineWidthOrApprox(line: LayoutCandidateLine, lang: Lang): number {
|
||||
if (typeof line.width === 'number' && Number.isFinite(line.width)) return roundToInt(line.width);
|
||||
return lang === 'EN' ? asInt(line.tokenCount) : asInt(line.charCount);
|
||||
}
|
||||
|
||||
function computeIdealBreakPositions(nTokens: number, lineCount: number): number[] {
|
||||
const n = Math.max(0, nTokens | 0);
|
||||
const targetLines = Math.max(1, Math.min(lineCount | 0, n === 0 ? 1 : n));
|
||||
const ideals: number[] = [];
|
||||
|
||||
for (let i = 1; i <= targetLines - 1; i++) {
|
||||
ideals.push(Math.round((n * i) / targetLines));
|
||||
}
|
||||
|
||||
ideals.sort((a, b) => a - b);
|
||||
return ideals.filter((v, idx) => idx === 0 || v !== ideals[idx - 1]);
|
||||
}
|
||||
|
||||
function sumAbs(a: number[], b: number[]): number {
|
||||
const m = Math.min(a.length, b.length);
|
||||
let s = 0;
|
||||
for (let i = 0; i < m; i++) s += Math.abs((a[i] ?? 0) - (b[i] ?? 0));
|
||||
// 若长度不等,用一个确定性惩罚补齐(避免 NaN,并让更“匹配理想结构”的更优)
|
||||
if (a.length !== b.length) s += 100000 * Math.abs(a.length - b.length);
|
||||
return s;
|
||||
}
|
||||
|
||||
function computeSpread(widths: number[]): number {
|
||||
if (widths.length === 0) return 0;
|
||||
let min = widths[0]!;
|
||||
let max = widths[0]!;
|
||||
for (const w of widths) {
|
||||
if (w < min) min = w;
|
||||
if (w > max) max = w;
|
||||
}
|
||||
return max - min;
|
||||
}
|
||||
|
||||
export type BuildTieKeyInput = {
|
||||
scoredLayout: ScoredLayout;
|
||||
layoutCandidate: LayoutCandidate;
|
||||
lang: Lang;
|
||||
context: TextWrapContext;
|
||||
/** tokens.length(用于理想切分点距离;必须与 breaks/lines 的 token 体系一致) */
|
||||
tokenCount: number;
|
||||
/** 可用宽度(用于理想切分点/尾行宽度判断的辅助项;tieKey 主要使用 width 值) */
|
||||
availableWidth: number;
|
||||
idealWidthRatio: { APP: number; WIDGET: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造 tieKey(按文档 11 节顺序,必须确定性):
|
||||
* 1) emotionSplit=false 优先
|
||||
* 2) overflowed=false 优先
|
||||
* 3) lastLineWidth 更大优先
|
||||
* 4) 行宽分布更均匀优先(max-min 更小)
|
||||
* 5) 断点更接近理想切分点优先(距离之和更小)
|
||||
* 6) breaks 字典序更靠前优先(在外部比较中按逐项数值比较即可)
|
||||
*/
|
||||
export function buildTieKey(args: BuildTieKeyInput): Array<number | string> {
|
||||
const { scoredLayout, layoutCandidate } = args;
|
||||
const lines = layoutCandidate.lines ?? [];
|
||||
const breaks = (layoutCandidate.breaks ?? []).slice().sort((a, b) => a - b);
|
||||
|
||||
const widths = lines.map((l) => lineWidthOrApprox(l, args.lang));
|
||||
const lastLineWidth = widths.length > 0 ? widths[widths.length - 1]! : 0;
|
||||
const spread = computeSpread(widths);
|
||||
|
||||
const ideals = computeIdealBreakPositions(args.tokenCount, lines.length);
|
||||
const idealDist = sumAbs(breaks, ideals);
|
||||
|
||||
const emotionSplitFlag = scoredLayout.flags.emotionSplit ? 1 : 0;
|
||||
const overflowedFlag = scoredLayout.flags.overflowed ? 1 : 0;
|
||||
|
||||
return [
|
||||
emotionSplitFlag,
|
||||
overflowedFlag,
|
||||
-lastLineWidth, // 更大优先 => 取负数实现“更小更优”
|
||||
spread, // 更小更优
|
||||
idealDist, // 更小更优
|
||||
...breaks,
|
||||
];
|
||||
}
|
||||
|
||||
92
client/src/features/textWrap/scoring/types.ts
Normal file
92
client/src/features/textWrap/scoring/types.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Lang, Token } from '../core/types';
|
||||
|
||||
export type TextWrapContext = 'APP' | 'WIDGET';
|
||||
|
||||
export type LayoutCandidateLine = {
|
||||
/**
|
||||
* 该行对应的 token 区间(半开区间),单位与 `breaks` 一致:
|
||||
* - start:包含
|
||||
* - end:不包含
|
||||
*/
|
||||
start: number;
|
||||
end: number;
|
||||
/** 该行展示文本(用于 debug;评分以 token/width 为主) */
|
||||
text: string;
|
||||
/**
|
||||
* 该行宽度(同一候选集必须同一单位)。
|
||||
* - App:像素等真实测量单位
|
||||
* - Widget approx:可使用近似单位(例如 wordCount/graphemeCount)
|
||||
*/
|
||||
width: number | null;
|
||||
/** 该行 token 数(EN=词数;TC=grapheme 数) */
|
||||
tokenCount: number;
|
||||
/** 该行字符簇数(TC 使用;EN 可等于 tokenCount 或 0) */
|
||||
charCount: number;
|
||||
};
|
||||
|
||||
export type LayoutCandidate = {
|
||||
/** 断点序列(token 边界索引),升序 */
|
||||
breaks: number[];
|
||||
/** 每行信息(lines.length = breaks.length + 1) */
|
||||
lines: LayoutCandidateLine[];
|
||||
/** 可选:外部模块(overflow/fallback)可透传的标记 */
|
||||
meta?: { overflowed?: boolean; fallback?: boolean };
|
||||
};
|
||||
|
||||
export type ScoreTerm = { key: string; delta: number; detail?: any };
|
||||
|
||||
export type ScoreBreakdown = { total: number; terms: ScoreTerm[] };
|
||||
|
||||
export type ScoredLayout = {
|
||||
score: number;
|
||||
flags: { emotionSplit?: boolean; overflowed?: boolean; fallback?: boolean };
|
||||
tieKey: Array<number | string>;
|
||||
scoreBreakdown?: ScoreBreakdown;
|
||||
};
|
||||
|
||||
export type Weights = Record<string, number>;
|
||||
|
||||
export type Lexicons = {
|
||||
emotionPhrasesTC: string[];
|
||||
emotionPhrasesEN: string[];
|
||||
protectedPhrases?: string[];
|
||||
shiftWordsTC: string[];
|
||||
shiftWordsEN: string[];
|
||||
accumWordsTC: string[];
|
||||
accumWordsEN: string[];
|
||||
selfWordsTC: string[];
|
||||
selfWordsEN: string[];
|
||||
emotionWordsTC?: string[];
|
||||
emotionWordsEN?: string[];
|
||||
};
|
||||
|
||||
export type ScoringConfig = {
|
||||
weights: Weights;
|
||||
idealWidthRatio: { APP: number; WIDGET: number };
|
||||
ellipsisToken: string;
|
||||
/** TC 标点集合(用于 PUNCT_BREAK 奖励与行首标点检测) */
|
||||
tcPunctuations?: string[];
|
||||
/** TC 助词集合(用于 PARTICLE_ISO) */
|
||||
tcParticles?: string[];
|
||||
/** TC 语尾语助词白名单:触发 PARTICLE_ISO 时惩罚减半 */
|
||||
tcParticleWhitelist: string[];
|
||||
/** Widow 短词阈值(EN):len(word) <= widowMaxLen(默认 3) */
|
||||
widowMaxLen?: number;
|
||||
/** 过短阈值:minPreferred = idealWidth * minPreferredRatio(默认 0.6) */
|
||||
minPreferredRatio?: number;
|
||||
/** 尾行过短阈值:shortLastLine = idealWidth * shortLastLineRatio(默认 0.5) */
|
||||
shortLastLineRatio?: number;
|
||||
};
|
||||
|
||||
export type ScoreLayoutInput = {
|
||||
tokens: Token[];
|
||||
layoutCandidate: LayoutCandidate;
|
||||
lang: Lang;
|
||||
context: TextWrapContext;
|
||||
/** 可用宽度(同 width 单位),必须由上游提供 */
|
||||
availableWidth: number;
|
||||
config: ScoringConfig;
|
||||
lexicons: Lexicons;
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
35
client/src/features/textWrap/scoring/weights.ts
Normal file
35
client/src/features/textWrap/scoring/weights.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Weights } from './types';
|
||||
|
||||
/**
|
||||
* scoring-tiebreak 默认权重(首版写死客户端)
|
||||
*
|
||||
* 来源:`设计说明文档/文档换行算法.md v1.2.1` 10.3
|
||||
*/
|
||||
export const DEFAULT_WEIGHTS = Object.freeze({
|
||||
P_EMOTION_SPLIT: 10000,
|
||||
P_PROTECTED_SPLIT: 10000,
|
||||
P_WIDOW_WORD: 800,
|
||||
P_WIDOW_LINE: 500,
|
||||
P_SHORT_LASTLINE: 300,
|
||||
P_PARTICLE_ISO: 200,
|
||||
R_PUNCT_BREAK: 80,
|
||||
R_SHIFT_BREAK: 60,
|
||||
R_ACCUM_BREAK: 40,
|
||||
R_SELF_BREAK: 20,
|
||||
P_OVER_MAXLEN: 30,
|
||||
P_TOO_SHORT: 10,
|
||||
|
||||
/**
|
||||
* 预留:情绪词落点强化(文档 7.2 / 10.2G 提到)
|
||||
* - 为避免偏离 10.3 首版权重,本实现默认置 0(不影响结果)
|
||||
* - 若后续需要启用,可通过 overrides 提供非 0 权重,并用 configVersion 管理
|
||||
*/
|
||||
R_EMOTION_TAIL: 0,
|
||||
P_EMOTION_BURIED: 0,
|
||||
} satisfies Weights);
|
||||
|
||||
export function mergeWeights(overrides?: Partial<Weights> | null | undefined): Weights {
|
||||
if (!overrides) return { ...DEFAULT_WEIGHTS };
|
||||
return { ...DEFAULT_WEIGHTS, ...overrides };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MeasureWidthImpl } from '../../measure/types';
|
||||
import { searchBestLayoutApp } from '../index';
|
||||
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from '../../scoring/index';
|
||||
|
||||
function t(text: string, start: number) {
|
||||
return { text, start, end: start + text.length };
|
||||
}
|
||||
|
||||
describe('textWrap search-engine-app', () => {
|
||||
it('确定性:同输入多次调用 breaks/lines 必须一致', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
const input = {
|
||||
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
|
||||
lang: 'EN' as const,
|
||||
breakpoints: [
|
||||
{ pos: 1, kind: 'SPACE', priority: 10 },
|
||||
{ pos: 2, kind: 'SPACE', priority: 10 },
|
||||
{ pos: 3, kind: 'SPACE', priority: 10 },
|
||||
],
|
||||
availableWidth: 6,
|
||||
maxLines: 2,
|
||||
lineMode: 'AUTO' as const,
|
||||
measure: {
|
||||
contextProfile: 'APP|ios|test',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
},
|
||||
scoring: {
|
||||
config: {
|
||||
weights: DEFAULT_WEIGHTS,
|
||||
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||
ellipsisToken: '…',
|
||||
tcParticleWhitelist: [],
|
||||
},
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
debug: true,
|
||||
},
|
||||
};
|
||||
|
||||
const a = await searchBestLayoutApp(input as any);
|
||||
const b = await searchBestLayoutApp(input as any);
|
||||
const c = await searchBestLayoutApp(input as any);
|
||||
|
||||
expect(a).toEqual(b);
|
||||
expect(b).toEqual(c);
|
||||
expect(impl).toHaveBeenCalled(); // 有测量发生
|
||||
});
|
||||
|
||||
it('lineMode=FIXED:无解必须返回 NO_CANDIDATE', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
const res = await searchBestLayoutApp({
|
||||
tokens: [t('I', 0), t('am', 2)],
|
||||
lang: 'EN',
|
||||
breakpoints: [{ pos: 1, kind: 'SPACE', priority: 10 }],
|
||||
availableWidth: 100,
|
||||
maxLines: 3,
|
||||
lineMode: 'FIXED',
|
||||
measure: {
|
||||
contextProfile: 'APP|ios|fixed',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
},
|
||||
scoring: {
|
||||
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
|
||||
});
|
||||
|
||||
it('TC H6:断点导致下一行行首为标点时必须禁止', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
const res = await searchBestLayoutApp({
|
||||
tokens: [t('我', 0), t('好', 1), t(',', 2), t('累', 3)],
|
||||
lang: 'TC',
|
||||
// 若选择 pos=2,则第二行行首 token 为 ','(应禁止)
|
||||
breakpoints: [{ pos: 2, kind: 'PUNCT', priority: 30 }],
|
||||
availableWidth: 100,
|
||||
maxLines: 2,
|
||||
lineMode: 'FIXED',
|
||||
measure: {
|
||||
contextProfile: 'APP|ios|tc',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
},
|
||||
scoring: {
|
||||
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], tcPunctuations: [','] },
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
},
|
||||
});
|
||||
|
||||
// lineMode=FIXED 且唯一断点被禁止,因此无解
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
|
||||
});
|
||||
|
||||
it('TOO_LONG:超过阈值直接返回 TOO_LONG', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
const tokens = Array.from({ length: 31 }).map((_, i) => t('a', i));
|
||||
const res = await searchBestLayoutApp({
|
||||
tokens,
|
||||
lang: 'EN',
|
||||
breakpoints: [],
|
||||
availableWidth: 100,
|
||||
maxLines: 2,
|
||||
lineMode: 'AUTO',
|
||||
measure: {
|
||||
contextProfile: 'APP|ios|toolong',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
},
|
||||
scoring: {
|
||||
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.reason).toBe('TOO_LONG');
|
||||
});
|
||||
});
|
||||
|
||||
21
client/src/features/textWrap/searchApp/constraints.ts
Normal file
21
client/src/features/textWrap/searchApp/constraints.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Token } from '../core/types';
|
||||
|
||||
export const DEFAULT_TOO_LONG_THRESHOLDS = Object.freeze({ EN: 30, TC: 60 });
|
||||
|
||||
export function isTooLong(tokens: Token[], lang: 'TC' | 'EN', thresholds: { EN: number; TC: number }): boolean {
|
||||
const n = Math.max(0, tokens.length | 0);
|
||||
const limit = lang === 'EN' ? thresholds.EN : thresholds.TC;
|
||||
return n > limit;
|
||||
}
|
||||
|
||||
export function isLineStartPunctTC(tokens: Token[], pos: number, tcPunctuations: string[]): boolean {
|
||||
const p = pos | 0;
|
||||
if (p <= 0) return false; // 第一行不受 H6 影响
|
||||
const t = tokens[p]?.text ?? '';
|
||||
return tcPunctuations.includes(t);
|
||||
}
|
||||
|
||||
export function isOverWidth(width: number, availableWidth: number): boolean {
|
||||
return width > availableWidth;
|
||||
}
|
||||
|
||||
221
client/src/features/textWrap/searchApp/dpTopK.ts
Normal file
221
client/src/features/textWrap/searchApp/dpTopK.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
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)) };
|
||||
}
|
||||
|
||||
4
client/src/features/textWrap/searchApp/index.ts
Normal file
4
client/src/features/textWrap/searchApp/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type { BestLayout, LineMode, SearchAppConfig, SearchAppInput, SearchAppResult, SearchFailureReason } from './types';
|
||||
|
||||
export { searchBestLayoutApp } from './dpTopK';
|
||||
|
||||
62
client/src/features/textWrap/searchApp/topK.ts
Normal file
62
client/src/features/textWrap/searchApp/topK.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { PartialLayout } from './types';
|
||||
import { compareBreaksLexicographically } from '../core/index';
|
||||
|
||||
function compareTieKey(a: number[], b: number[]): number {
|
||||
const n = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const av = a[i] ?? 0;
|
||||
const bv = b[i] ?? 0;
|
||||
if (av < bv) return -1;
|
||||
if (av > bv) return 1;
|
||||
}
|
||||
if (a.length < b.length) return -1;
|
||||
if (a.length > b.length) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* TopK 排序(确定性):
|
||||
* - score 越大越优
|
||||
* - tieKey 越小越优(按 11 节构造的数值 key)
|
||||
* - breaks 字典序更小者更优(11.0A)
|
||||
*/
|
||||
export function compareLayouts(a: PartialLayout, b: PartialLayout): number {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
const t = compareTieKey(a.tieKey, b.tieKey);
|
||||
if (t !== 0) return t;
|
||||
return compareBreaksLexicographically(a.breaks, b.breaks);
|
||||
}
|
||||
|
||||
function breaksKey(breaks: number[]): string {
|
||||
// breaks 序列作为去重 key(确定性)
|
||||
return breaks.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入 TopK(去重 + 排序 + 截断)。
|
||||
*
|
||||
* 去重规则:
|
||||
* - 同 breaks 仅保留最优(score 更高;若相同按 tieKey/breaks 比较)
|
||||
*/
|
||||
export function insertTopK(list: PartialLayout[], cand: PartialLayout, k: number): PartialLayout[] {
|
||||
const K = Math.max(1, k | 0);
|
||||
const out = list.slice();
|
||||
|
||||
const key = breaksKey(cand.breaks);
|
||||
const idx = out.findIndex((x) => breaksKey(x.breaks) === key);
|
||||
if (idx >= 0) {
|
||||
const existing = out[idx]!;
|
||||
if (compareLayouts(cand, existing) < 0) {
|
||||
// cand 更差,忽略
|
||||
return out;
|
||||
}
|
||||
out[idx] = cand;
|
||||
} else {
|
||||
out.push(cand);
|
||||
}
|
||||
|
||||
out.sort(compareLayouts);
|
||||
if (out.length > K) out.length = K;
|
||||
return out;
|
||||
}
|
||||
|
||||
71
client/src/features/textWrap/searchApp/types.ts
Normal file
71
client/src/features/textWrap/searchApp/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Token } from '../core/types';
|
||||
import type { Breakpoint } from '../breakpoints/types';
|
||||
import type { ContextProfile, FontSpec, MeasureWidthImpl } from '../measure/types';
|
||||
import type { Lexicons, ScoringConfig, ScoreTerm } from '../scoring/types';
|
||||
|
||||
export type LineMode = 'AUTO' | 'FIXED';
|
||||
|
||||
export type SearchAppConfig = {
|
||||
/** TopK(文档建议 App=10) */
|
||||
topK: number;
|
||||
/** 超长阈值(用于 TOO_LONG) */
|
||||
tooLongThresholds: { EN: number; TC: number };
|
||||
};
|
||||
|
||||
export type SearchMeasureInput = {
|
||||
contextProfile: ContextProfile;
|
||||
fontSpec: Partial<FontSpec> | null | undefined;
|
||||
measureWidthImpl?: MeasureWidthImpl;
|
||||
};
|
||||
|
||||
export type SearchScoringInput = {
|
||||
config: ScoringConfig;
|
||||
lexicons: Lexicons;
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
export type SearchAppInput = {
|
||||
tokens: Token[];
|
||||
lang: 'TC' | 'EN';
|
||||
breakpoints: Breakpoint[];
|
||||
availableWidth: number;
|
||||
maxLines: number;
|
||||
lineMode: LineMode;
|
||||
measure: SearchMeasureInput;
|
||||
scoring: SearchScoringInput;
|
||||
config?: Partial<SearchAppConfig>;
|
||||
};
|
||||
|
||||
export type BestLayout = {
|
||||
breaks: number[];
|
||||
lines: string[];
|
||||
wrappedText: string;
|
||||
meta?: {
|
||||
breaks: number[];
|
||||
scoreTopTerms?: ScoreTerm[];
|
||||
score?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SearchFailureReason = 'TOO_LONG' | 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | 'NO_CANDIDATE';
|
||||
|
||||
export type SearchAppResult =
|
||||
| { ok: true; bestLayout: BestLayout }
|
||||
| { ok: false; reason: SearchFailureReason; meta?: { tokenCount: number } };
|
||||
|
||||
export type PartialLayout = {
|
||||
pos: number;
|
||||
breaks: number[];
|
||||
lines: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
width: number;
|
||||
tokenCount: number;
|
||||
charCount: number;
|
||||
}>;
|
||||
score: number;
|
||||
tieKey: number[];
|
||||
scoreTopTerms?: ScoreTerm[];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MeasureWidthImpl } from '../../measure/types';
|
||||
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from '../../scoring/index';
|
||||
import { searchBestLayoutWidget } from '../index';
|
||||
|
||||
function t(text: string, start: number) {
|
||||
return { text, start, end: start + text.length };
|
||||
}
|
||||
|
||||
describe('textWrap search-engine-widget', () => {
|
||||
it('确定性:同输入多次调用 breaks/lines 必须一致', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
const input = {
|
||||
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
|
||||
lang: 'EN' as const,
|
||||
breakpoints: [
|
||||
{ pos: 1, kind: 'SPACE', priority: 10 },
|
||||
{ pos: 2, kind: 'SPACE', priority: 10 },
|
||||
{ pos: 3, kind: 'SPACE', priority: 10 },
|
||||
],
|
||||
availableWidth: 6,
|
||||
maxLines: 2,
|
||||
context: 'WIDGET' as const,
|
||||
measure: {
|
||||
widthMode: 'MEASURE' as const,
|
||||
contextProfile: 'WIDGET|small|test',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
widgetEnableMeasure: true,
|
||||
},
|
||||
scoring: {
|
||||
config: {
|
||||
weights: DEFAULT_WEIGHTS,
|
||||
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||
ellipsisToken: '…',
|
||||
tcParticleWhitelist: [],
|
||||
},
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
debug: true,
|
||||
},
|
||||
config: { beamK: 5, expandM: 12 },
|
||||
};
|
||||
|
||||
const a = await searchBestLayoutWidget(input as any);
|
||||
const b = await searchBestLayoutWidget(input as any);
|
||||
const c = await searchBestLayoutWidget(input as any);
|
||||
|
||||
expect(a).toEqual(b);
|
||||
expect(b).toEqual(c);
|
||||
});
|
||||
|
||||
it('widthMode=APPROX:仍可输出,并标记 meta.reason=WIDTH_UNKNOWN', async () => {
|
||||
const res = await searchBestLayoutWidget({
|
||||
tokens: [t('我', 0), t('好', 1), t('累', 2)],
|
||||
lang: 'TC',
|
||||
breakpoints: [{ pos: 1, kind: 'BALANCE', priority: 5 }],
|
||||
// APPROX 模式下:availableWidth 与 width 单位都按“token 数”理解
|
||||
availableWidth: 10,
|
||||
maxLines: 2,
|
||||
context: 'WIDGET',
|
||||
measure: { widthMode: 'APPROX' },
|
||||
scoring: {
|
||||
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
debug: true,
|
||||
},
|
||||
config: { beamK: 3, expandM: 2 },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.bestLayout.meta?.reason).toBe('WIDTH_UNKNOWN');
|
||||
}
|
||||
});
|
||||
|
||||
it('性能约束:expandM/beamK 生效(通过测量调用次数粗略验证)', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
const res = await searchBestLayoutWidget({
|
||||
tokens: [t('a', 0), t('b', 2), t('c', 4), t('d', 6), t('e', 8)],
|
||||
lang: 'EN',
|
||||
breakpoints: [
|
||||
{ pos: 1, kind: 'SPACE', priority: 10 },
|
||||
{ pos: 2, kind: 'SPACE', priority: 10 },
|
||||
{ pos: 3, kind: 'SPACE', priority: 10 },
|
||||
{ pos: 4, kind: 'SPACE', priority: 10 },
|
||||
],
|
||||
availableWidth: 100,
|
||||
maxLines: 3,
|
||||
context: 'WIDGET',
|
||||
measure: {
|
||||
widthMode: 'MEASURE',
|
||||
contextProfile: 'WIDGET|perf',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
widgetEnableMeasure: true,
|
||||
},
|
||||
scoring: {
|
||||
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
},
|
||||
config: { beamK: 2, expandM: 2 },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
// 每轮最多 beamK 个 beam,每个 beam 最多 expandM 次扩展 -> 粗略上限:maxLines*beamK*expandM
|
||||
expect((impl as any).mock.calls.length).toBeLessThanOrEqual(3 * 2 * 2 + 2);
|
||||
});
|
||||
|
||||
it('TC H6:行首标点导致的转移必须被禁止', async () => {
|
||||
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||
const res = await searchBestLayoutWidget({
|
||||
tokens: [t('我', 0), t('好', 1), t(',', 2), t('累', 3)],
|
||||
lang: 'TC',
|
||||
breakpoints: [{ pos: 2, kind: 'PUNCT', priority: 30 }],
|
||||
// 让“一行放下全文”不可能(触发超宽过滤),从而必须依赖断点切分
|
||||
availableWidth: 3,
|
||||
maxLines: 2,
|
||||
context: 'WIDGET',
|
||||
measure: {
|
||||
widthMode: 'MEASURE',
|
||||
contextProfile: 'WIDGET|tc',
|
||||
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||
measureWidthImpl: impl,
|
||||
widgetEnableMeasure: true,
|
||||
},
|
||||
scoring: {
|
||||
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], tcPunctuations: [','] },
|
||||
lexicons: DEFAULT_LEXICONS,
|
||||
},
|
||||
config: { beamK: 3, expandM: 3 },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
|
||||
});
|
||||
});
|
||||
|
||||
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)) };
|
||||
}
|
||||
|
||||
21
client/src/features/textWrap/searchWidget/constraints.ts
Normal file
21
client/src/features/textWrap/searchWidget/constraints.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Token } from '../core/types';
|
||||
|
||||
export const DEFAULT_TOO_LONG_THRESHOLDS = Object.freeze({ EN: 30, TC: 60 });
|
||||
|
||||
export function isTooLong(tokens: Token[], lang: 'TC' | 'EN', thresholds: { EN: number; TC: number }): boolean {
|
||||
const n = Math.max(0, tokens.length | 0);
|
||||
const limit = lang === 'EN' ? thresholds.EN : thresholds.TC;
|
||||
return n > limit;
|
||||
}
|
||||
|
||||
export function isLineStartPunctTC(tokens: Token[], nextPos: number, tcPunctuations: string[]): boolean {
|
||||
const p = nextPos | 0;
|
||||
if (p <= 0) return false;
|
||||
const t = tokens[p]?.text ?? '';
|
||||
return tcPunctuations.includes(t);
|
||||
}
|
||||
|
||||
export function approxWidth(tokenCount: number, charCount: number, lang: 'TC' | 'EN'): number {
|
||||
return lang === 'EN' ? tokenCount : charCount;
|
||||
}
|
||||
|
||||
13
client/src/features/textWrap/searchWidget/index.ts
Normal file
13
client/src/features/textWrap/searchWidget/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type {
|
||||
BestWidgetLayout,
|
||||
SearchWidgetConfig,
|
||||
SearchWidgetFailureReason,
|
||||
SearchWidgetInput,
|
||||
SearchWidgetMeasureInput,
|
||||
SearchWidgetResult,
|
||||
WidthMode,
|
||||
WidgetBeam,
|
||||
} from './types';
|
||||
|
||||
export { searchBestLayoutWidget } from './beam';
|
||||
|
||||
52
client/src/features/textWrap/searchWidget/topK.ts
Normal file
52
client/src/features/textWrap/searchWidget/topK.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { compareBreaksLexicographically } from '../core/index';
|
||||
import type { WidgetBeam } from './types';
|
||||
|
||||
function compareTieKey(a: number[], b: number[]): number {
|
||||
const n = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const av = a[i] ?? 0;
|
||||
const bv = b[i] ?? 0;
|
||||
if (av < bv) return -1;
|
||||
if (av > bv) return 1;
|
||||
}
|
||||
if (a.length < b.length) return -1;
|
||||
if (a.length > b.length) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* BeamK 排序(确定性):
|
||||
* - score 越大越优
|
||||
* - tieKey 越小越优
|
||||
* - breaks 字典序更小者更优(11.0A)
|
||||
*/
|
||||
export function compareBeams(a: WidgetBeam, b: WidgetBeam): number {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
const t = compareTieKey(a.tieKey, b.tieKey);
|
||||
if (t !== 0) return t;
|
||||
return compareBreaksLexicographically(a.breaks, b.breaks);
|
||||
}
|
||||
|
||||
function breaksKey(breaks: number[]): string {
|
||||
return breaks.join(',');
|
||||
}
|
||||
|
||||
export function insertBeamTopK(list: WidgetBeam[], cand: WidgetBeam, k: number): WidgetBeam[] {
|
||||
const K = Math.max(1, k | 0);
|
||||
const out = list.slice();
|
||||
|
||||
const key = breaksKey(cand.breaks);
|
||||
const idx = out.findIndex((x) => breaksKey(x.breaks) === key);
|
||||
if (idx >= 0) {
|
||||
const existing = out[idx]!;
|
||||
if (compareBeams(cand, existing) < 0) return out;
|
||||
out[idx] = cand;
|
||||
} else {
|
||||
out.push(cand);
|
||||
}
|
||||
|
||||
out.sort(compareBeams);
|
||||
if (out.length > K) out.length = K;
|
||||
return out;
|
||||
}
|
||||
|
||||
91
client/src/features/textWrap/searchWidget/types.ts
Normal file
91
client/src/features/textWrap/searchWidget/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Token } from '../core/types';
|
||||
import type { Breakpoint } from '../breakpoints/types';
|
||||
import type { ContextProfile, FontSpec, MeasureWidthImpl } from '../measure/types';
|
||||
import type { Lexicons, ScoringConfig, ScoreTerm } from '../scoring/types';
|
||||
|
||||
export type WidthMode = 'MEASURE' | 'APPROX';
|
||||
|
||||
export type SearchWidgetConfig = {
|
||||
beamK: number;
|
||||
expandM: number;
|
||||
tooLongThresholds: { EN: number; TC: number };
|
||||
};
|
||||
|
||||
export type SearchWidgetMeasureInput =
|
||||
| { widthMode: 'APPROX' }
|
||||
| {
|
||||
widthMode: 'MEASURE';
|
||||
contextProfile: ContextProfile;
|
||||
fontSpec: Partial<FontSpec> | null | undefined;
|
||||
measureWidthImpl?: MeasureWidthImpl;
|
||||
/** Widget 是否启用测量;默认 true(本模块仅在明确 MEASURE 时开启) */
|
||||
widgetEnableMeasure?: boolean;
|
||||
};
|
||||
|
||||
export type SearchWidgetScoringInput = {
|
||||
config: ScoringConfig;
|
||||
lexicons: Lexicons;
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
export type SearchWidgetInput = {
|
||||
tokens: Token[];
|
||||
lang: 'TC' | 'EN';
|
||||
breakpoints: Breakpoint[];
|
||||
/**
|
||||
* 可用宽度(与 line.width 的单位必须一致):
|
||||
* - MEASURE:像素等真实测量单位
|
||||
* - APPROX:建议用“近似单位”(例如每行可容纳的 token 数)
|
||||
*/
|
||||
availableWidth: number;
|
||||
maxLines: number;
|
||||
context: 'WIDGET';
|
||||
measure: SearchWidgetMeasureInput;
|
||||
scoring: SearchWidgetScoringInput;
|
||||
config?: Partial<SearchWidgetConfig>;
|
||||
};
|
||||
|
||||
export type WidgetLayoutMeta = {
|
||||
breaks: number[];
|
||||
scoreTopTerms?: ScoreTerm[];
|
||||
score?: number;
|
||||
/**
|
||||
* 仅当进入 approx/降级时填入,便于 integration/打点模块统一处理:
|
||||
* - WIDTH_UNKNOWN:未启用测量或测量能力缺失
|
||||
* - MEASURE_FAILED:测量抛错或返回非法
|
||||
*/
|
||||
reason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
|
||||
};
|
||||
|
||||
export type BestWidgetLayout = {
|
||||
breaks: number[];
|
||||
lines: string[];
|
||||
wrappedText: string;
|
||||
meta?: WidgetLayoutMeta;
|
||||
};
|
||||
|
||||
export type SearchWidgetFailureReason = 'TOO_LONG' | 'NO_CANDIDATE';
|
||||
|
||||
export type SearchWidgetResult =
|
||||
| { ok: true; bestLayout: BestWidgetLayout }
|
||||
| { ok: false; reason: SearchWidgetFailureReason; meta?: { tokenCount: number } };
|
||||
|
||||
export type WidgetBeam = {
|
||||
pos: number;
|
||||
breaks: number[];
|
||||
lines: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
width: number;
|
||||
tokenCount: number;
|
||||
charCount: number;
|
||||
/** 该行是否为近似宽度(用于 meta.reason) */
|
||||
isApprox: boolean;
|
||||
approxReason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
|
||||
}>;
|
||||
score: number;
|
||||
tieKey: number[];
|
||||
scoreTopTerms?: ScoreTerm[];
|
||||
};
|
||||
|
||||
55
client/src/features/textWrap/types.ts
Normal file
55
client/src/features/textWrap/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { MeasureWidthImpl } from './measure/types';
|
||||
import type { ScoreTerm } from './scoring/types';
|
||||
|
||||
export type OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT';
|
||||
export type LineMode = 'AUTO' | 'FIXED';
|
||||
export type TextWrapContext = 'APP' | 'WIDGET';
|
||||
export type Lang = 'TC' | 'EN';
|
||||
|
||||
export type WrapTextConstraints = {
|
||||
protectedPhrases?: string[];
|
||||
forbiddenBreakRanges?: Array<{ start: number; end: number }>;
|
||||
};
|
||||
|
||||
export type FontSpecInput = {
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: string;
|
||||
};
|
||||
|
||||
export type WrapTextInput = {
|
||||
text: string;
|
||||
lang: Lang;
|
||||
availableWidth: number;
|
||||
maxLines: number;
|
||||
context: TextWrapContext;
|
||||
fontSpec?: Partial<FontSpecInput> | null;
|
||||
/** 可选注入测量实现(APP 场景强烈建议提供;WIDGET 默认不启用) */
|
||||
measureWidthImpl?: MeasureWidthImpl;
|
||||
/**
|
||||
* 测量缓存隔离 key(可选)。
|
||||
* 口径建议:`APP|ios|<scale?>` / `WIDGET|small`。
|
||||
*/
|
||||
contextProfile?: string;
|
||||
overflowMode?: OverflowMode;
|
||||
lineMode?: LineMode;
|
||||
constraints?: WrapTextConstraints;
|
||||
configVersion?: string;
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
export type WrapTextMeta = {
|
||||
configVersion?: string;
|
||||
fallback_type?: 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT';
|
||||
overflow_type?: 'NONE' | 'ELLIPSIS' | 'CLIP';
|
||||
reason?: 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'WIDOW' | 'PARTICLE' | 'TOO_LONG' | string;
|
||||
breaks?: number[];
|
||||
scoreTopTerms?: ScoreTerm[];
|
||||
};
|
||||
|
||||
export type WrapTextOutput = {
|
||||
lines: string[];
|
||||
wrappedText: string;
|
||||
meta?: WrapTextMeta;
|
||||
};
|
||||
|
||||
173
client/src/features/textWrap/wrapText.ts
Normal file
173
client/src/features/textWrap/wrapText.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Token } from './core/types';
|
||||
import { normalizeWhitespace, tokenizeEN } from './core/index';
|
||||
import { segmentGraphemes } from './grapheme/index';
|
||||
import { generateBreakpoints } from './breakpoints/index';
|
||||
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from './scoring/index';
|
||||
import { searchBestLayoutApp } from './searchApp/index';
|
||||
import { searchBestLayoutWidget } from './searchWidget/index';
|
||||
import { applyOverflowFallback } from './overflow/index';
|
||||
|
||||
import type { WrapTextInput, WrapTextMeta, WrapTextOutput } from './types';
|
||||
|
||||
function tokenizeTC(normalizedText: string): Token[] {
|
||||
const { clusters } = segmentGraphemes(normalizedText, 'PREFERRED');
|
||||
const tokens: Token[] = [];
|
||||
let idx = 0;
|
||||
for (const c of clusters) {
|
||||
const start = idx;
|
||||
idx += c.length;
|
||||
tokens.push({ text: c, start, end: idx });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export async function wrapText(input: WrapTextInput): Promise<WrapTextOutput> {
|
||||
const lang = input.lang;
|
||||
const context = input.context;
|
||||
const configVersion = input.configVersion ?? 'v1';
|
||||
const overflowMode: WrapTextInput['overflowMode'] =
|
||||
input.overflowMode ?? (context === 'WIDGET' ? 'ELLIPSIS' : 'CLIP');
|
||||
const lineMode = input.lineMode ?? 'AUTO';
|
||||
const debug = Boolean(input.debug);
|
||||
|
||||
const { normalizedText } = normalizeWhitespace(input.text, 'NORMALIZE');
|
||||
const tokens = lang === 'EN' ? tokenizeEN(normalizedText) : tokenizeTC(normalizedText);
|
||||
|
||||
// 断点候选
|
||||
const { breakpoints } = generateBreakpoints({
|
||||
tokens: tokens as any,
|
||||
lang,
|
||||
maxLines: input.maxLines,
|
||||
constraints: input.constraints,
|
||||
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [',', '。', '!', '?', ';', ':', '、'], balanceRange: 3 },
|
||||
});
|
||||
|
||||
// scoring:protectedPhrases 从 constraints 注入
|
||||
const lexicons = { ...DEFAULT_LEXICONS, protectedPhrases: input.constraints?.protectedPhrases ?? DEFAULT_LEXICONS.protectedPhrases };
|
||||
const tcPunctuations: string[] = [',', '。', '!', '?', ';', ':', '、'];
|
||||
const scoringConfig = {
|
||||
weights: DEFAULT_WEIGHTS,
|
||||
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||
ellipsisToken: '…',
|
||||
tcParticleWhitelist: [],
|
||||
tcPunctuations,
|
||||
};
|
||||
|
||||
// 搜索
|
||||
if (context === 'APP') {
|
||||
const res = await searchBestLayoutApp({
|
||||
tokens,
|
||||
lang,
|
||||
breakpoints: breakpoints as any,
|
||||
availableWidth: input.availableWidth,
|
||||
maxLines: input.maxLines,
|
||||
lineMode,
|
||||
measure: {
|
||||
contextProfile: input.contextProfile ?? 'APP',
|
||||
fontSpec: input.fontSpec ?? null,
|
||||
measureWidthImpl: input.measureWidthImpl,
|
||||
},
|
||||
scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const meta: WrapTextMeta = { configVersion, breaks: res.bestLayout.meta?.breaks, scoreTopTerms: res.bestLayout.meta?.scoreTopTerms };
|
||||
return { lines: res.bestLayout.lines, wrappedText: res.bestLayout.wrappedText, meta };
|
||||
}
|
||||
|
||||
// APP:若测量失败/不可用,且调用方未要求 SYSTEM_DEFAULT,则尝试降级为“近似宽度 Beam”
|
||||
// 目的:保证尽量产出带 \n 的 wrappedText(而不是完全依赖系统自动换行)
|
||||
if (overflowMode !== 'SYSTEM_DEFAULT' && (res.reason === 'WIDTH_UNKNOWN' || res.reason === 'MEASURE_FAILED')) {
|
||||
const fontSize = Number.isFinite((input.fontSpec as any)?.fontSize) ? Number((input.fontSpec as any)?.fontSize) : 22;
|
||||
// 把像素宽度粗略换算为“token 容量”,只用于降级路径(确定性)
|
||||
const approxCapacity =
|
||||
lang === 'EN'
|
||||
? Math.max(1, Math.floor(input.availableWidth / Math.max(1, Math.round(fontSize * 0.55))))
|
||||
: Math.max(1, Math.floor(input.availableWidth / Math.max(1, Math.round(fontSize * 0.95))));
|
||||
|
||||
const approxRes = await searchBestLayoutWidget({
|
||||
tokens,
|
||||
lang,
|
||||
breakpoints: breakpoints as any,
|
||||
availableWidth: approxCapacity,
|
||||
maxLines: input.maxLines,
|
||||
context: 'WIDGET',
|
||||
measure: { widthMode: 'APPROX' },
|
||||
scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug },
|
||||
config: { beamK: 5, expandM: 12 },
|
||||
});
|
||||
|
||||
if (approxRes.ok) {
|
||||
const meta: WrapTextMeta = {
|
||||
configVersion,
|
||||
breaks: approxRes.bestLayout.meta?.breaks,
|
||||
scoreTopTerms: approxRes.bestLayout.meta?.scoreTopTerms,
|
||||
reason: res.reason,
|
||||
};
|
||||
return { lines: approxRes.bestLayout.lines, wrappedText: approxRes.bestLayout.wrappedText, meta };
|
||||
}
|
||||
}
|
||||
|
||||
// overflow/fallback(最终兜底)
|
||||
const fb = await applyOverflowFallback({
|
||||
tokens,
|
||||
lang,
|
||||
context: 'APP',
|
||||
availableWidth: input.availableWidth,
|
||||
maxLines: input.maxLines,
|
||||
overflowMode: overflowMode as any,
|
||||
ellipsisToken: scoringConfig.ellipsisToken,
|
||||
reason: res.reason,
|
||||
partialLayout: null,
|
||||
tcPunctuations: scoringConfig.tcPunctuations,
|
||||
});
|
||||
|
||||
return {
|
||||
lines: fb.lines,
|
||||
wrappedText: fb.wrappedText,
|
||||
meta: { configVersion, fallback_type: fb.meta.fallback_type, overflow_type: fb.meta.overflow_type, reason: fb.meta.reason },
|
||||
};
|
||||
}
|
||||
|
||||
// WIDGET:默认 APPROX(单位由 availableWidth 决定),可在上层选择 MEASURE 并注入测量能力
|
||||
const resW = await searchBestLayoutWidget({
|
||||
tokens,
|
||||
lang,
|
||||
breakpoints: breakpoints as any,
|
||||
availableWidth: input.availableWidth,
|
||||
maxLines: input.maxLines,
|
||||
context: 'WIDGET',
|
||||
measure: { widthMode: 'APPROX' },
|
||||
scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug },
|
||||
});
|
||||
|
||||
if (resW.ok) {
|
||||
const meta: WrapTextMeta = {
|
||||
configVersion,
|
||||
breaks: resW.bestLayout.meta?.breaks,
|
||||
scoreTopTerms: resW.bestLayout.meta?.scoreTopTerms,
|
||||
reason: resW.bestLayout.meta?.reason,
|
||||
};
|
||||
return { lines: resW.bestLayout.lines, wrappedText: resW.bestLayout.wrappedText, meta };
|
||||
}
|
||||
|
||||
const fb = await applyOverflowFallback({
|
||||
tokens,
|
||||
lang,
|
||||
context: 'WIDGET',
|
||||
availableWidth: input.availableWidth,
|
||||
maxLines: input.maxLines,
|
||||
overflowMode: overflowMode as any,
|
||||
ellipsisToken: scoringConfig.ellipsisToken,
|
||||
reason: resW.reason,
|
||||
partialLayout: null,
|
||||
tcPunctuations: scoringConfig.tcPunctuations,
|
||||
});
|
||||
|
||||
return {
|
||||
lines: fb.lines,
|
||||
wrappedText: fb.wrappedText,
|
||||
meta: { configVersion, fallback_type: fb.meta.fallback_type, overflow_type: fb.meta.overflow_type, reason: fb.meta.reason },
|
||||
};
|
||||
}
|
||||
|
||||
Binary file not shown.
162
spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md
Normal file
162
spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# breakpoint-candidates(技术计划)
|
||||
|
||||
## 1. 计划目标
|
||||
|
||||
基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,实现“候选断点生成与裁剪”模块,输出**可控规模、确定性排序**的 breakpoints 集合,保证:
|
||||
|
||||
- EN/TC 断点生成口径一致(断点 `pos` 均是 token 边界索引)
|
||||
- 同输入必定同输出(去重、排序、裁剪与过滤全流程确定性)
|
||||
- 候选规模上限严格生效(尤其 TC)
|
||||
- 支持约束过滤:`forbiddenBreakRanges`(必须)与 `protectedPhrases`(可选优化)
|
||||
|
||||
本模块只产出 breakpoints,不做组合搜索与评分。
|
||||
|
||||
## 2. 默认技术决策(本计划采用)
|
||||
|
||||
- **输出结构**:`Array<{ pos, kind, priority }>`,最终按 `pos` 升序
|
||||
- **去重策略**:同一 `pos` 若出现多个来源候选,保留 `priority` 更高者(priority 相同按 `kind` 固定序优先)
|
||||
- **裁剪策略(TC)**:先按“候选重要性排序”截断到 `tcMaxCandidateBreaks`,再按 `pos` 升序输出
|
||||
- **过滤策略**:
|
||||
- 必做:`forbiddenBreakRanges` 命中直接剔除
|
||||
- 可选优化:若已计算 `protectedPhrases` 的 span,可在生成阶段剔除 span 内断点(否则交给评分阶段强惩罚淘汰)
|
||||
|
||||
## 3. 输入/输出与关键口径
|
||||
|
||||
### 3.1 输入(来自上游)
|
||||
|
||||
- `tokens: Token[]`
|
||||
- EN:WORD tokens(不包含 SPACE token)
|
||||
- TC:grapheme cluster tokens(允许包含空格 cluster `" "`,用于 SPACE 断点)
|
||||
- `lang: 'TC' | 'EN'`
|
||||
- `maxLines: number`
|
||||
- `constraints?: { protectedPhrases?: string[]; forbiddenBreakRanges?: Array<{ start: number; end: number }> }`
|
||||
- `config: { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }`
|
||||
|
||||
### 3.2 输出(确定性)
|
||||
|
||||
- `breakpoints: Array<{ pos: number; kind: 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER'; priority: number }>`
|
||||
- `pos`:token 边界索引,范围 `0..N`
|
||||
- **最终输出必须按 `pos` 升序**
|
||||
- `meta?: { pruned: boolean; originalCount: number; finalCount: number }`
|
||||
|
||||
### 3.3 断点边界定义(统一口径)
|
||||
|
||||
- 候选断点只生成在“行内断点”位置:`pos ∈ [1, N-1]`
|
||||
- `pos=0` 与 `pos=N` 由搜索器作为“起止边界”处理(不作为候选断点输出)
|
||||
|
||||
## 4. 生成规则(按语言)
|
||||
|
||||
### 4.1 EN:词边界(kind=SPACE)
|
||||
|
||||
#### 规则
|
||||
|
||||
- tokens 仅为 WORD,不生成 SPACE token
|
||||
- 对每个词边界产生候选断点:
|
||||
- 对 `i in 1..N-1` 生成 `pos=i, kind='SPACE'`
|
||||
- `priority` 固定为基础值(建议 `priority=10`)
|
||||
|
||||
#### 验收要点
|
||||
|
||||
- `"I am so tired"`(N=4)→ breakpoints.pos 必为 `[1,2,3]`(升序)
|
||||
|
||||
### 4.2 TC:标点/空格/BALANCE
|
||||
|
||||
#### 4.2.1 标点后断点(kind=PUNCT,最高优先级)
|
||||
|
||||
- 若 `tokens[i].text` 属于 `tcPunctuations`:
|
||||
- 生成 `pos=i+1, kind='PUNCT'`
|
||||
- `priority` 建议最高(例如 `priority=30`)
|
||||
|
||||
#### 4.2.2 空格后断点(kind=SPACE,中优先级)
|
||||
|
||||
- 若 `tokens[i].text === ' '`:
|
||||
- 生成 `pos=i+1, kind='SPACE'`
|
||||
- `priority` 建议中等(例如 `priority=20`)
|
||||
|
||||
> 注:若上游对 TC 也做了空白 NORMALIZE,则空格通常不会连写,断点仍保持确定性。
|
||||
|
||||
#### 4.2.3 BALANCE 断点(kind=BALANCE,低优先级)
|
||||
|
||||
目的:在无标点时,仍在“接近理想位置”附近提供少量断点,提升可解性与观感。
|
||||
|
||||
**理想位置计算(确定性简化版)**:
|
||||
|
||||
- `N = tokens.length`
|
||||
- `targetLines = min(maxLines, N)`(至少 1,且不超过 N)
|
||||
- 对 `lineIndex in 1..targetLines-1`:
|
||||
- `idealPos = round((N * lineIndex) / targetLines)`
|
||||
- 在区间 `[idealPos - balanceRange, idealPos + balanceRange]` 生成少量候选 `pos`
|
||||
|
||||
**生成细则**:
|
||||
|
||||
- 候选 `pos` 必须落在 `[1, N-1]`
|
||||
- 去重前可以允许重复(后续统一去重)
|
||||
- `priority` 建议最低(例如 `priority=5`)
|
||||
|
||||
> 裁决补充口径:BALANCE 断点允许落在 emotionPhrase/protectedPhrases 的 span 内;是否可用由评分阶段强惩罚决定(本模块不做语义裁决)。
|
||||
|
||||
## 5. 过滤、去重、裁剪与排序(必须确定性)
|
||||
|
||||
### 5.1 forbiddenBreakRanges 过滤(必须)
|
||||
|
||||
- 若 `pos` 落在任一 `forbiddenBreakRanges` 的区间内(按项目约定:`start <= pos <= end` 或半开区间,必须写死一种),则剔除该 breakpoint
|
||||
- 过滤必须发生在最终输出前,保证确定性
|
||||
|
||||
### 5.2 去重(必须)
|
||||
|
||||
同一 `pos` 可能来自多个来源(例如 PUNCT 与 BALANCE):
|
||||
|
||||
- 取 `priority` 更高者
|
||||
- `priority` 相同则按固定 kind 序:`PUNCT > SPACE > BALANCE > OTHER`
|
||||
|
||||
### 5.3 TC 规模上限裁剪(必须)
|
||||
|
||||
当 `lang=TC` 且候选数超过 `tcMaxCandidateBreaks`:
|
||||
|
||||
1. 计算每个 pos 到最近 `idealPos` 的距离 `distToIdeal`(若无 idealPos 列表则设为大值)
|
||||
2. 按以下 key 排序后截断(排序必须固定):
|
||||
- `priority` 降序
|
||||
- `distToIdeal` 升序
|
||||
- `pos` 升序
|
||||
3. 取前 `tcMaxCandidateBreaks`
|
||||
|
||||
最后再按 `pos` 升序输出(输出顺序固定)。
|
||||
|
||||
### 5.4 meta 输出
|
||||
|
||||
- `originalCount`:过滤/去重/裁剪前的候选数量
|
||||
- `finalCount`:最终输出数量
|
||||
- `pruned`:是否发生过裁剪(finalCount < originalCount)
|
||||
|
||||
## 6. 测试计划(Vitest)
|
||||
|
||||
### 6.1 EN 断点生成
|
||||
|
||||
- 输入 tokens=[I, am, so, tired] → pos=[1,2,3](确定性)
|
||||
|
||||
### 6.2 TC 标点/空格断点
|
||||
|
||||
- `tokens=['我','好','累',',','😮💨']` 且 `tcPunctuations` 包含 `,`
|
||||
- 必须包含 `pos=4(kind=PUNCT)`
|
||||
|
||||
### 6.3 BALANCE 与裁剪
|
||||
|
||||
- 构造无标点长文本,balanceRange>0 且 `tcMaxCandidateBreaks` 很小
|
||||
- 验证裁剪后数量上限生效
|
||||
- 验证输出仍按 pos 升序
|
||||
|
||||
### 6.4 forbiddenBreakRanges
|
||||
|
||||
- 给定 ranges,断言命中区间内的 pos 一律被剔除
|
||||
|
||||
### 6.5 确定性(关键)
|
||||
|
||||
- 同输入多次调用 breakpoints 输出完全一致(包括 meta)
|
||||
|
||||
## 7. 完成定义(DoD)
|
||||
|
||||
- EN/TC 候选断点生成口径与裁剪规则写死并实现
|
||||
- 去重/排序/裁剪/过滤流程完全确定性
|
||||
- TC 上限 `tcMaxCandidateBreaks` 生效
|
||||
- 单测覆盖:EN/TC 基础、BALANCE、裁剪、forbiddenBreakRanges、确定性
|
||||
|
||||
51
spec_kit/Text Wrap/modules/breakpoint-candidates/spec.md
Normal file
51
spec_kit/Text Wrap/modules/breakpoint-candidates/spec.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# breakpoint-candidates(子模块规范)
|
||||
|
||||
## 子模块名称
|
||||
|
||||
breakpoint-candidates(候选断点生成与裁剪)
|
||||
|
||||
## 目标描述
|
||||
|
||||
基于 token 序列生成“可控规模、确定性排序”的候选断点集合(breakpoints),并应用去重、排序与约束过滤,确保后续搜索器复杂度可控且跨端一致。
|
||||
|
||||
本模块输出的是“断点候选集合”,不负责“组合搜索选最优”。
|
||||
|
||||
## 输入/输出定义
|
||||
|
||||
### 输入
|
||||
|
||||
- `tokens: Token[]`
|
||||
- `lang: 'TC' | 'EN'`
|
||||
- `maxLines: number`
|
||||
- `constraints?: { protectedPhrases?: string[]; forbiddenBreakRanges?: Array<{ start: number; end: number }> }`
|
||||
- `config: { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }`
|
||||
|
||||
### 输出
|
||||
|
||||
- `breakpoints: Array<{ pos: number; kind: 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER'; priority: number }>`
|
||||
- **pos**:token 边界索引(0..N)
|
||||
- **排序**:按 `pos` 升序(最终输出必须确定性)
|
||||
- `meta?: { pruned: boolean; originalCount: number; finalCount: number }`
|
||||
|
||||
## 验收标准(可验证)
|
||||
|
||||
- **EN 口径**:
|
||||
- tokens 仅为 WORD(不生成 SPACE token),断点仅存在于词间
|
||||
- 每个词边界产生 `kind=SPACE` 的候选断点(按配置可做裁剪,但必须确定性)
|
||||
- **TC 口径**:
|
||||
- 标点后断点 `kind=PUNCT` 优先级最高
|
||||
- 空格后断点 `kind=SPACE` 次之
|
||||
- BALANCE 断点:围绕理想切分点附近生成少量断点(允许落在短语 span 内,是否可用交给评分惩罚)
|
||||
- **去重/排序/过滤确定性**:
|
||||
- 同一 `pos` 多来源断点:保留 priority 更高者
|
||||
- 输出按 `pos` 升序
|
||||
- `forbiddenBreakRanges` 命中者必定被剔除
|
||||
- **规模上限生效**:
|
||||
- TC 输出候选断点数不超过 `tcMaxCandidateBreaks`
|
||||
- 截断策略确定性(按 priority + 距离理想位置等固定规则)
|
||||
|
||||
## 依赖与关联
|
||||
|
||||
- **依赖**:`core-contract`(token 与索引语义)、`grapheme-segmentation`(TC tokens)
|
||||
- **被依赖**:`search-engine-app`、`search-engine-widget`
|
||||
|
||||
164
spec_kit/Text Wrap/modules/breakpoint-candidates/tasks.md
Normal file
164
spec_kit/Text Wrap/modules/breakpoint-candidates/tasks.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# breakpoint-candidates(任务清单)
|
||||
|
||||
> 对应计划:`spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md`
|
||||
>
|
||||
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
|
||||
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 任务标记规则
|
||||
|
||||
- 用勾选框标记执行状态:
|
||||
- `[ ]` 未完成
|
||||
- `[x]` 已完成
|
||||
- 每个任务必须可独立验收(有明确产出与检查方式)。
|
||||
- 所有代码注释必须为简体中文,并把“去重/裁剪/排序/过滤”的**确定性口径**写死,避免后续模块漂移。
|
||||
|
||||
---
|
||||
|
||||
## 1. 前置对齐(口径必须写死)
|
||||
|
||||
- [x] 1.1 明确 `pos` 的边界范围:仅输出 `pos ∈ [1, N-1]`
|
||||
- **原因**:`pos=0/N` 属于搜索器的起止边界,不应作为候选断点
|
||||
- **验收**:单测覆盖 `N=0/1/2` 等边界输入,不会输出非法 pos。
|
||||
|
||||
- [x] 1.2 明确 `forbiddenBreakRanges` 的区间口径(写死一种)
|
||||
- **本任务采用**:闭区间 `start <= pos && pos <= end`
|
||||
- **验收**:单测能验证闭区间边界命中(start/end 两端都被剔除)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录与代码骨架(客户端侧实现)
|
||||
|
||||
- [x] 2.1 新建目录 `client/src/features/textWrap/breakpoints/`
|
||||
- **产出**(建议文件):
|
||||
- `types.ts`(Breakpoint/Config/Constraints)
|
||||
- `generateBreakpoints.ts`(主入口,纯函数)
|
||||
- `tcCandidates.ts`(TC:PUNCT/SPACE/BALANCE 生成)
|
||||
- `enCandidates.ts`(EN:SPACE 断点生成)
|
||||
- `filterAndDedup.ts`(过滤/去重/排序/裁剪)
|
||||
- `__tests__/generateBreakpoints.test.ts`
|
||||
- `index.ts`(统一导出)
|
||||
- **验收**:目录存在,TS 可正常 import(不报路径错误)。
|
||||
|
||||
- [x] 2.2 定义最小类型集合(只覆盖本模块)
|
||||
- **必须包含**:
|
||||
- `Breakpoint = { pos: number; kind: 'PUNCT'|'SPACE'|'BALANCE'|'OTHER'; priority: number }`
|
||||
- `BreakpointMeta = { pruned: boolean; originalCount: number; finalCount: number }`
|
||||
- `Constraints = { forbiddenBreakRanges?: Array<{ start: number; end: number }>; protectedPhrases?: string[] }`
|
||||
- `Config = { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }`
|
||||
- **验收**:后续实现文件引用类型清晰,且不会引入无关依赖。
|
||||
|
||||
---
|
||||
|
||||
## 3. 断点生成(按语言)
|
||||
|
||||
- [x] 3.1 EN 候选断点生成(kind=SPACE,priority 固定)
|
||||
- **规则**:
|
||||
- 对 `i in 1..N-1` 生成 `pos=i, kind='SPACE'`
|
||||
- `priority=10`(写死)
|
||||
- **验收**:
|
||||
- tokens=[I, am, so, tired] → pos=[1,2,3]
|
||||
|
||||
- [x] 3.2 TC:标点后断点(kind=PUNCT)
|
||||
- **规则**:
|
||||
- 若 `tokens[i].text ∈ tcPunctuations`,生成 `pos=i+1, kind='PUNCT', priority=30`
|
||||
- **验收**:
|
||||
- tokens=['我','好','累',',','😮💨'] → 包含 `pos=4(kind=PUNCT)`
|
||||
|
||||
- [x] 3.3 TC:空格后断点(kind=SPACE)
|
||||
- **规则**:
|
||||
- 若 `tokens[i].text === ' '`,生成 `pos=i+1, kind='SPACE', priority=20`
|
||||
- **验收**:构造含空格 tokens,断点生成稳定且不越界。
|
||||
|
||||
- [x] 3.4 TC:BALANCE 断点生成(kind=BALANCE)
|
||||
- **规则**:
|
||||
- `targetLines = min(maxLines, N)`
|
||||
- 对 `lineIndex in 1..targetLines-1`:
|
||||
- `idealPos = round((N * lineIndex) / targetLines)`
|
||||
- 在 `[idealPos-balanceRange, idealPos+balanceRange]` 内生成 pos(裁剪到 `[1,N-1]`)
|
||||
- `priority=5`
|
||||
- **验收**:
|
||||
- 无标点长文本:可生成接近理想位置的候选断点(数量受控、确定性)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 过滤、去重、裁剪与最终排序(必须确定性)
|
||||
|
||||
- [x] 4.1 forbiddenBreakRanges 过滤(闭区间)
|
||||
- **规则**:命中任一 range 则剔除该 `pos`
|
||||
- **验收**:range 边界 start/end 都会剔除。
|
||||
|
||||
- [x] 4.2 去重:同 pos 只保留一个 breakpoint
|
||||
- **规则**:
|
||||
- priority 更高者优先
|
||||
- priority 相同按 kind 固定序:`PUNCT > SPACE > BALANCE > OTHER`
|
||||
- **验收**:构造同 pos 多来源候选,结果唯一且确定性。
|
||||
|
||||
- [x] 4.3 TC 裁剪:超过 `tcMaxCandidateBreaks` 时截断(确定性排序后截断)
|
||||
- **排序 key(写死)**:
|
||||
- priority 降序
|
||||
- distToIdeal 升序(到最近 idealPos 的距离;无 idealPos 时为大值)
|
||||
- pos 升序
|
||||
- **验收**:
|
||||
- 当候选数 > 上限时,finalCount==tcMaxCandidateBreaks
|
||||
- 截断结果稳定(同输入同输出)
|
||||
|
||||
- [x] 4.4 最终输出排序:按 `pos` 升序
|
||||
- **验收**:无论内部裁剪排序如何,最终输出始终 `pos` 升序。
|
||||
|
||||
- [x] 4.5 meta 输出
|
||||
- **规则**:
|
||||
- `originalCount`:过滤/去重/裁剪前的候选数量
|
||||
- `finalCount`:最终输出数量
|
||||
- `pruned = finalCount < originalCount`
|
||||
- **验收**:单测断言 meta 与候选数量一致。
|
||||
|
||||
---
|
||||
|
||||
## 5. 单元测试(Vitest)
|
||||
|
||||
- [x] 5.1 新建 `generateBreakpoints.test.ts`,覆盖 EN 基础用例
|
||||
- **验收**:pos=[1,2,3] 且升序。
|
||||
|
||||
- [x] 5.2 覆盖 TC:PUNCT/SPACE/BALANCE 生成
|
||||
- **验收**:关键样例存在,且 BALANCE 不越界。
|
||||
|
||||
- [x] 5.3 覆盖 forbiddenBreakRanges(闭区间)
|
||||
- **验收**:start/end 命中都剔除。
|
||||
|
||||
- [x] 5.4 覆盖去重与 kind 优先级
|
||||
- **验收**:同 pos 多候选时输出唯一且正确 kind。
|
||||
|
||||
- [x] 5.5 覆盖 TC 裁剪上限与确定性
|
||||
- **验收**:
|
||||
- 数量上限严格生效
|
||||
- 同输入多次调用输出完全一致(包括 meta)
|
||||
|
||||
---
|
||||
|
||||
## 6. 最终自检清单(合入前)
|
||||
|
||||
- [x] 6.1 `npm test` 通过(包含本模块新增用例)
|
||||
- **验收**:不影响现有测试文件。
|
||||
|
||||
- [x] 6.2 `npx tsc --noEmit` 通过(或项目既有 TS 检查命令通过)
|
||||
- **验收**:无类型错误。
|
||||
|
||||
- [x] 6.3 注释与口径自检(简体中文)
|
||||
- **检查点**:
|
||||
- `pos` 边界范围 `[1,N-1]`
|
||||
- forbiddenBreakRanges 闭区间口径
|
||||
- 去重优先级与裁剪排序 key 的固定顺序
|
||||
- **验收**:后续模块开发者只看代码也不会产生歧义。
|
||||
|
||||
---
|
||||
|
||||
## 7. 文档回写(任务清单执行完毕后必须做)
|
||||
|
||||
- [x] 7.1 在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充执行状态
|
||||
- **建议写法**:
|
||||
- 增加一行:`- **已完成编码(阶段性)**:breakpoint-candidates(候选断点生成与裁剪)`
|
||||
- **验收**:overview 能反映该子模块已完成,便于全局追踪。
|
||||
|
||||
111
spec_kit/Text Wrap/modules/core-contract/plan.md
Normal file
111
spec_kit/Text Wrap/modules/core-contract/plan.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# core-contract(技术计划)
|
||||
|
||||
## 1. 计划目标
|
||||
|
||||
基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,落地跨端一致的“基础口径与契约”,为后续断点生成、搜索与评分提供稳定输入与确定性工具,确保:
|
||||
|
||||
- EN/TC 的 **token 索引体系** 与断点 `pos` 语义固定
|
||||
- 文本 **可重组**:任意 `[start..end)` 区间可稳定还原为行文本
|
||||
- EN 关键词命中规则严格为 **全词等值匹配**(避免 substring 误伤)
|
||||
- 空白归一化策略可配置但默认一致(推荐 NORMALIZE)
|
||||
- 提供可复用的 **确定性比较工具**(用于 breaks 字典序/tieKey 比较)
|
||||
|
||||
## 2. 默认技术决策(本计划采用)
|
||||
|
||||
- **空白策略**:默认 `whitespacePolicy=NORMALIZE`
|
||||
- 行为:折叠连续空白为 1 个空格、去首尾空白
|
||||
- 并在 meta(后续模块)中打点 `hadMultiWhitespace`(本模块先预留布尔返回位)
|
||||
- **EN tokenize**:仅生成 WORD token(不生成 SPACE token),以空白分隔;标点按“极简派”保留在词内
|
||||
- **TC tokenize**:本模块只定义接口,具体分割由 `grapheme-segmentation` 提供
|
||||
- **EN 关键词命中**:`lowercase → strip 两端常见标点 → 等值比较`,禁止 contains/substring
|
||||
- **确定性比较**:breaks 字典序比较按“逐项比较 + 公共前缀相同则更短者更小”
|
||||
|
||||
## 3. 目录与产物
|
||||
|
||||
本子模块目录:
|
||||
|
||||
- `spec_kit/Text Wrap/modules/core-contract/spec.md`
|
||||
- `spec_kit/Text Wrap/modules/core-contract/plan.md`(本文)
|
||||
|
||||
建议未来代码落位(实现阶段再定,不在本计划强制):
|
||||
|
||||
- `client/src/features/textWrap/core/`(或 `client/src/utils/textWrap/`)
|
||||
|
||||
## 4. 设计与实现要点(按落地顺序)
|
||||
|
||||
### 4.1 文本预处理:normalizeWhitespace
|
||||
|
||||
实现 `normalizeWhitespace(text) -> { normalizedText, hadMultiWhitespace }`:
|
||||
|
||||
- 规则:
|
||||
- 把任意连续空白(空格/制表/换行等)折叠为单个空格
|
||||
- 去除首尾空白
|
||||
- 注意:
|
||||
- 该规范会改变输入文本;必须作为“算法契约”的一部分固定下来
|
||||
- 若后续产品需要保留原始空白,则走 `PRESERVE` 分支并输出 `rawSeparators`(见 4.3)
|
||||
|
||||
### 4.2 EN tokenize:word tokens(极简派)
|
||||
|
||||
实现 `tokenizeEN(normalizedText) -> tokens[]`:
|
||||
|
||||
- 以空格分隔生成 token
|
||||
- token 只包含 WORD,标点视为词内字符(例如 `tired.`、`Wait...`、`hello—world`、`don't` 都是单 token)
|
||||
- 输出 token 的 `text/start/end`(start/end 为原始或 normalized 的字符区间,需固定口径;建议以 normalizedText 为基准)
|
||||
|
||||
### 4.3 文本重组:joinTokens
|
||||
|
||||
实现 `joinTokens(tokens, start, end, separators?) -> string`:
|
||||
|
||||
- EN 默认:使用单空格 `" "` join `[start..end)` 的 token.text
|
||||
- 若 `whitespacePolicy=PRESERVE`:
|
||||
- 需要 `rawSeparators[i]` 表示 tokens[i] 与 tokens[i+1] 间的原始分隔符
|
||||
- 重组时按 separators 拼接(本计划仅定义接口与行为)
|
||||
|
||||
### 4.4 EN 关键词命中:normalizeENKeyword + matchENKeyword
|
||||
|
||||
实现:
|
||||
|
||||
- `normalizeENKeyword(tokenText) -> string`
|
||||
- `lowercase`
|
||||
- `strip` 两端常见标点(集合需配置化并全端一致,默认参考文档:`, . ! ? : ; " ' … — – ( ) [ ] { }`)
|
||||
- `matchENKeyword(tokenText, keyword) -> boolean`
|
||||
- `normalizeENKeyword(tokenText) === normalizeENKeyword(keyword)`
|
||||
- 禁止 `includes/contains` 类 substring 命中
|
||||
|
||||
### 4.5 断点/区间的索引契约
|
||||
|
||||
固化约定(后续模块必须复用,不得自行发挥):
|
||||
|
||||
- token 索引:`tokens[0..N-1]`
|
||||
- 断点 `pos`:位于 token 边界,切分为 `[0..pos)` 与 `[pos..N)`
|
||||
- 行区间:`[start..end)` 表示 `tokens[start] ... tokens[end-1]`
|
||||
|
||||
## 5. 回归用例与验证方式
|
||||
|
||||
### 5.1 必测示例(EN)
|
||||
|
||||
- `"I am so tired"` → tokens=`[I, am, so, tired]`
|
||||
- `pos=2` → `"I am"` / `"so tired"`
|
||||
- 标点极简派:
|
||||
- `"tired."` 为单 token
|
||||
- 关键词命中:
|
||||
- keyword=`"but"`:`"but,"` 命中;`"rebuttal"` 不命中
|
||||
|
||||
### 5.2 确定性检查
|
||||
|
||||
- 同一输入在同一配置下多次调用:
|
||||
- `normalizedText`、`tokens[]`、`joinTokens()`、`matchENKeyword()` 输出完全一致
|
||||
|
||||
## 6. 风险与规避
|
||||
|
||||
- **start/end 索引口径漂移**:若不同端选择以原始 text 或 normalizedText 计数,可能导致 span 对不齐
|
||||
- 规避:本模块明确 start/end 以 normalizedText 为准(或在实现阶段统一选择一种并写入 README/注释)
|
||||
- **标点集合不一致**:strip 集合若跨端不同会导致命中差异
|
||||
- 规避:将 `punctuationStripSetEN` 写入配置并版本化,禁止散落常量
|
||||
|
||||
## 7. 完成定义(DoD)
|
||||
|
||||
- `core-contract/spec.md` 中定义的输入/输出与验收条目均有可运行的最小实现或可验证的约束说明
|
||||
- EN tokenize / 空白归一化 / 关键词命中 / breaks 字典序比较口径写清楚且可复现
|
||||
- 关键示例用例可在本地/CI 以单元测试或脚本方式验证(实现阶段落地)
|
||||
|
||||
59
spec_kit/Text Wrap/modules/core-contract/spec.md
Normal file
59
spec_kit/Text Wrap/modules/core-contract/spec.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# core-contract(子模块规范)
|
||||
|
||||
## 子模块名称
|
||||
|
||||
core-contract(核心口径与契约)
|
||||
|
||||
## 目标描述
|
||||
|
||||
定义并固化跨端一致的“基础口径”,为后续断点生成、搜索与评分提供统一契约,避免实现偏差:
|
||||
|
||||
- **索引体系**:EN/TC 的 token 与断点 `pos` 语义
|
||||
- **文本重组**:从 token 区间稳定重组回行文本
|
||||
- **规范化**:空白归一化策略(默认折叠空白、去首尾)
|
||||
- **EN 关键词命中规则**:全词等值匹配(`lowercase → strip 两端常见标点 → 等值比较`),禁止 substring/contains
|
||||
- **配置与版本**:`configVersion` 的语义与回溯字段;全端一致的默认值入口
|
||||
- **确定性比较**:layout tie-break 的字典序比较口径(作为后续模块复用工具)
|
||||
|
||||
本模块不负责“换行搜索”,只负责**定义数据结构与基础函数**。
|
||||
|
||||
## 输入/输出定义
|
||||
|
||||
### 输入
|
||||
|
||||
- `text: string`
|
||||
- `lang: 'TC' | 'EN'`
|
||||
- `options?: { preserveRawSeparators?: boolean }`
|
||||
- `config: { punctuationStripSetEN: string[]; whitespacePolicy: 'NORMALIZE' | 'PRESERVE' }`
|
||||
|
||||
### 输出
|
||||
|
||||
- `normalizedText: string`
|
||||
- `tokens: Array<{ text: string; start: number; end: number }>`
|
||||
- EN:token 仅为 WORD(不产生 SPACE token;标点视为词内字符)
|
||||
- TC:token 为 grapheme cluster(具体分割由 `grapheme-segmentation` 模块实现/提供)
|
||||
- `rawSeparators?: string[]`
|
||||
- 可选:当选择保留原始空白时,输出 token 间分隔符映射
|
||||
- 基础工具函数(逻辑输出):
|
||||
- `joinTokens(start, end) -> string`
|
||||
- `normalizeENKeyword(tokenText) -> string`
|
||||
- `matchENKeyword(tokenText, keyword) -> boolean`
|
||||
|
||||
## 验收标准(可验证)
|
||||
|
||||
- **索引语义一致**:
|
||||
- EN:`"I am so tired"` tokens=`[I, am, so, tired]`,断点 `pos=2` 必然切为 `"I am"` / `"so tired"`
|
||||
- TC:断点 `pos` 表示在第 `pos` 个 grapheme 之前断开
|
||||
- **EN 标点极简派一致**:
|
||||
- `"tired."` 作为一个 token;断点只允许在词与词之间
|
||||
- **EN 关键词命中无误伤**:
|
||||
- keyword=`"but"`:`"but,"` 命中;`"rebuttal"` 不命中
|
||||
- **空白归一化确定性**:
|
||||
- 输入含多空格/首尾空白时,输出 `normalizedText` 可预测且稳定
|
||||
- **工具函数确定性**:相同输入在多次调用与多端实现中输出一致
|
||||
|
||||
## 依赖与关联
|
||||
|
||||
- **被依赖**:`breakpoint-candidates`、`scoring-tiebreak`、`search-engine-*`、`overflow-fallback`、`integration`
|
||||
- **依赖**:TC token 分割依赖 `grapheme-segmentation`
|
||||
|
||||
150
spec_kit/Text Wrap/modules/core-contract/tasks.md
Normal file
150
spec_kit/Text Wrap/modules/core-contract/tasks.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# core-contract(任务清单)
|
||||
|
||||
> 对应计划:`spec_kit/Text Wrap/modules/core-contract/plan.md`
|
||||
>
|
||||
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
|
||||
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 任务标记规则
|
||||
|
||||
- 用勾选框标记执行状态:
|
||||
- `[ ]` 未完成
|
||||
- `[x]` 已完成
|
||||
- 每个任务必须可独立验收(有明确产出与检查方式)。
|
||||
- 涉及“口径”的任务,必须在代码注释中写清楚(简体中文),避免后续模块实现漂移。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档对齐(先把口径写死,避免实现漂移)
|
||||
|
||||
- [x] 1.1 复核 `core-contract/spec.md` 与 `core-contract/plan.md` 的一致性
|
||||
- **检查点**:
|
||||
- `whitespacePolicy`(NORMALIZE/PRESERVE)语义一致
|
||||
- EN token 规则为“极简派”(不拆标点、不生成 SPACE token)
|
||||
- EN 关键词命中为“全词等值匹配”(禁止 substring)
|
||||
- breaks 字典序比较规则清晰且无歧义
|
||||
- **验收**:两份文档无冲突描述;关键字段命名一致。
|
||||
|
||||
- [x] 1.2 明确 `start/end` 的索引口径(以 normalizedText 为基准)并写入代码注释与 README(如有)
|
||||
- **原因**:跨端 span/调试定位会依赖该口径
|
||||
- **验收**:任意 token 的 `start/end` 都可映射到同一份文本基准(normalizedText)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录与代码骨架(客户端侧优先落地)
|
||||
|
||||
> 说明:当前仓库已有 `client/src/features/*` 结构,Text Wrap 建议也放到 `features/` 下,便于后续 Home/Widget 复用。
|
||||
|
||||
- [x] 2.1 新建目录 `client/src/features/textWrap/core/`
|
||||
- **产出**(建议文件):
|
||||
- `types.ts`:Token/Config/Options 类型
|
||||
- `normalizeWhitespace.ts`
|
||||
- `tokenizeEN.ts`
|
||||
- `joinTokens.ts`
|
||||
- `enKeyword.ts`(normalizeENKeyword/matchENKeyword)
|
||||
- `compare.ts`(breaks 字典序比较)
|
||||
- `index.ts`(统一导出)
|
||||
- **验收**:目录存在且可被 TS 正常 import(不报路径错误)。
|
||||
|
||||
- [x] 2.2 定义 `Token` 与基础配置类型(只含 core-contract 需要的字段)
|
||||
- **要求**:
|
||||
- `Token` 至少包含 `text/start/end`
|
||||
- `CoreConfig` 至少包含 `whitespacePolicy` 与 `punctuationStripSetEN`
|
||||
- **验收**:类型定义满足后续函数签名需要,且命名清晰。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心函数实现(纯函数 + 确定性)
|
||||
|
||||
- [x] 3.1 实现 `normalizeWhitespace(text)`(默认 NORMALIZE)
|
||||
- **规则**:
|
||||
- 连续空白折叠为单个空格
|
||||
- 去除首尾空白
|
||||
- 返回 `hadMultiWhitespace`(用于后续 meta 打点)
|
||||
- **验收**:
|
||||
- 输入 `" a b \n c "` 输出 `"a b c"`
|
||||
- `hadMultiWhitespace` 在出现折叠/trim 时为 true
|
||||
|
||||
- [x] 3.2 实现 `tokenizeEN(normalizedText)`(极简派)
|
||||
- **规则**:
|
||||
- 以空格切分为 WORD tokens
|
||||
- 标点作为 token.text 的一部分(不拆)
|
||||
- 不生成 SPACE token
|
||||
- **验收**:
|
||||
- `"I am so tired"` → `[I, am, so, tired]`
|
||||
- `"tired."` 为单 token
|
||||
|
||||
- [x] 3.3 实现 `joinTokens(tokens, start, end, separators?)`
|
||||
- **规则**:
|
||||
- 默认用单空格 join `[start..end)` 的 token.text
|
||||
- `start/end` 为半开区间,越界/空区间需有明确行为(建议:空区间返回空字符串,交由上层硬约束处理)
|
||||
- **验收**:
|
||||
- tokens=`[I, am, so, tired]`,`join(0,2)` 为 `"I am"`
|
||||
|
||||
- [x] 3.4 实现 EN 关键词命中:`normalizeENKeyword` + `matchENKeyword`
|
||||
- **规则**:
|
||||
- `lowercase`
|
||||
- strip 两端常见标点(使用配置 `punctuationStripSetEN`,全端一致)
|
||||
- 等值比较(禁止 substring)
|
||||
- **验收**:
|
||||
- keyword=`but`:`but,` 命中;`rebuttal` 不命中
|
||||
|
||||
- [x] 3.5 实现 breaks 字典序比较 `compareBreaksLexicographically(a, b)`
|
||||
- **规则**:
|
||||
- 从 index=0 起逐项比较,首个不同元素更小者更小
|
||||
- 公共前缀相同则更短数组更小
|
||||
- **验收**:
|
||||
- `[2] < [3]`
|
||||
- `[2] < [2, 5]`
|
||||
- `[2, 3] > [2]`
|
||||
|
||||
---
|
||||
|
||||
## 4. 单元测试(Vitest,纯函数为主)
|
||||
|
||||
- [x] 4.1 新建测试目录 `client/src/features/textWrap/core/__tests__/`
|
||||
- **验收**:测试文件可被现有 test runner 发现。
|
||||
|
||||
- [x] 4.2 为 `normalizeWhitespace` 增加用例
|
||||
- **覆盖**:多空格、换行、首尾空白、空字符串、全空白字符串
|
||||
- **验收**:测试断言输出字符串与 `hadMultiWhitespace` 符合预期。
|
||||
|
||||
- [x] 4.3 为 `tokenizeEN` + `joinTokens` 增加用例
|
||||
- **覆盖**:普通句子、带标点的 token、单词间多个空格(先 normalize 再 tokenize)
|
||||
- **验收**:tokens 序列与 join 后文本完全一致且确定。
|
||||
|
||||
- [x] 4.4 为 `matchENKeyword` 增加用例
|
||||
- **覆盖**:大小写、两端标点、误伤样例(rebuttal vs but)
|
||||
- **验收**:命中与不命中行为符合 spec。
|
||||
|
||||
- [x] 4.5 为 breaks 字典序比较增加用例
|
||||
- **验收**:比较规则在多组数组上输出稳定顺序。
|
||||
|
||||
---
|
||||
|
||||
## 5. 最终自检清单(合入前)
|
||||
|
||||
- [x] 5.1 `tsc --noEmit` 通过(或项目既有 TS 检查命令通过)
|
||||
- **验收**:无类型错误。
|
||||
|
||||
- [x] 5.2 `vitest` 通过(或项目既有测试命令通过)
|
||||
- **验收**:新增用例全部通过,不影响现有测试。
|
||||
|
||||
- [x] 5.3 代码注释口径检查(简体中文)
|
||||
- **检查点**:
|
||||
- EN 极简派与“断点只在词间”
|
||||
- 全词等值匹配(禁止 substring)
|
||||
- `start/end` 基于 normalizedText 的口径说明
|
||||
- **验收**:后续模块开发者只看代码也不会产生歧义。
|
||||
|
||||
---
|
||||
|
||||
## 6. 文档回写(任务清单执行完毕后必须做)
|
||||
|
||||
- [x] 6.1 在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充执行状态
|
||||
- **建议写法**:
|
||||
- 增加一行:`- **已完成编码(阶段性)**:core-contract(核心口径与契约)`
|
||||
- **验收**:overview 能反映该子模块已完成,便于全局追踪。
|
||||
|
||||
57
spec_kit/Text Wrap/modules/golden-tests/plan.md
Normal file
57
spec_kit/Text Wrap/modules/golden-tests/plan.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# golden-tests(技术计划)
|
||||
|
||||
## 1. 计划目标
|
||||
|
||||
建立可持续回归体系,覆盖:
|
||||
|
||||
- **Golden Cases**:固定输入 → 固定输出(lines/wrappedText/meta),作为“可治理基线”
|
||||
- **性质测试(property tests)**:
|
||||
- 确定性:同输入多次调用输出一致
|
||||
- 近似单调性:availableWidth 变小不会让任一行变得更宽(同测量口径下)
|
||||
- maxLines 不变差:maxLines 增加时至少不从可解变 overflow
|
||||
|
||||
本模块产出测试数据与测试规则,不产出业务功能。
|
||||
|
||||
## 2. 约束与策略
|
||||
|
||||
### 2.1 测量可控
|
||||
|
||||
- APP:使用稳定的测量 mock(例如 `width = text.length`)保证 CI 可运行
|
||||
- WIDGET:使用固定 profile 或 `widthMode=APPROX`(单位为 tokenCount/graphemeCount)
|
||||
|
||||
### 2.2 Golden 的组织方式
|
||||
|
||||
- 采用 TS fixture(便于类型校验与可读性)
|
||||
- Golden case 至少包含:
|
||||
- `text/lang/context/availableWidth/maxLines/overflowMode/lineMode/configVersion`
|
||||
- 期望:`expected.lines/expected.wrappedText`(可选 meta 断言)
|
||||
|
||||
### 2.3 覆盖面(首版)
|
||||
|
||||
首版优先覆盖“易回归且高价值”的样例集:
|
||||
|
||||
- EN/TC 各若干条:短/长、含标点/无标点、含 emoji、含 shift/accum/self、极窄宽度
|
||||
|
||||
> 说明:文档建议每种语言 20 条;首版先落最小可运行集合,后续迭代扩充但保持可解释与版本化。
|
||||
|
||||
## 3. 测试实现结构(客户端)
|
||||
|
||||
- `client/src/features/textWrap/golden/fixtures.ts`(Golden cases)
|
||||
- `client/src/features/textWrap/golden/__tests__/golden.test.ts`
|
||||
- Golden 断言(lines/wrappedText)
|
||||
- 性质测试(determinism/monotonic/maxLines)
|
||||
|
||||
测试内暂时使用“测试版 wrapTextHarness”把已实现模块串起来:
|
||||
|
||||
- normalize/tokenize(EN=tokenizeEN,TC=segmentGraphemes)
|
||||
- generateBreakpoints
|
||||
- search-engine-app / search-engine-widget
|
||||
|
||||
待 integration 模块产出正式 `wrapText()` 后,再把测试入口切到正式函数(不改变期望数据)。
|
||||
|
||||
## 4. 完成定义(DoD)
|
||||
|
||||
- Golden fixtures + 测试可在 CI 一键运行
|
||||
- 至少包含 EN/TC 的基础样例与 3 类性质测试
|
||||
- overview 更新记录变更文件
|
||||
|
||||
57
spec_kit/Text Wrap/modules/golden-tests/spec.md
Normal file
57
spec_kit/Text Wrap/modules/golden-tests/spec.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# golden-tests(子模块规范)
|
||||
|
||||
## 子模块名称
|
||||
|
||||
golden-tests(Golden Cases 与性质测试)
|
||||
|
||||
## 目标描述
|
||||
|
||||
建立可持续的回归体系,保证换行算法满足:
|
||||
|
||||
- 同输入同输出(确定性)
|
||||
- 跨端一致(在固定测量/宽度 profile 下)
|
||||
- 规则变更可控(通过 `configVersion` 回溯)
|
||||
|
||||
本模块产出的是测试数据与测试规则,不产出业务功能。
|
||||
|
||||
## 输入/输出定义
|
||||
|
||||
### 输入
|
||||
|
||||
- Golden Case 集合(建议 JSON/TS fixture):
|
||||
- `text`
|
||||
- `lang`
|
||||
- `context`
|
||||
- `availableWidth`
|
||||
- `maxLines`
|
||||
- `fontSpec?`(APP)
|
||||
- `constraints?`
|
||||
- `overflowMode?`
|
||||
- `configVersion`
|
||||
|
||||
### 输出
|
||||
|
||||
- 对每个 case 的期望输出:
|
||||
- `expected.lines: string[]`
|
||||
- `expected.wrappedText: string`
|
||||
- 可选:`expected.meta.breaks`、`expected.meta.fallback_type/overflow_type/reason`
|
||||
|
||||
并定义性质测试(property tests):
|
||||
|
||||
- **确定性**:同输入多次调用输出一致
|
||||
- **近似单调性**:`availableWidth` 变小不会让任一行变得更宽(在同测量模式下)
|
||||
- **maxLines 不变差**:`maxLines` 增加时至少不从可解变 overflow
|
||||
|
||||
## 验收标准(可验证)
|
||||
|
||||
- **覆盖度**:
|
||||
- 每种语言至少 20 个样例:短/长、含标点/无标点、含 emoji、含 protectedPhrases、含 shift/accum/self、极窄宽度
|
||||
- **固定口径**:
|
||||
- 测量可控:APP 用稳定的测量 mock(或固定字体与平台);WIDGET 用固定 width profile
|
||||
- **CI 可运行**:在自动化环境可一键跑完(不依赖人工操作)
|
||||
|
||||
## 依赖与关联
|
||||
|
||||
- **依赖**:`core-contract`、`search-engine-*`、`scoring-tiebreak`、`overflow-fallback`
|
||||
- **被依赖**:`integration`(接入后验收也可引用 Golden)
|
||||
|
||||
50
spec_kit/Text Wrap/modules/golden-tests/tasks.md
Normal file
50
spec_kit/Text Wrap/modules/golden-tests/tasks.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# golden-tests(任务清单)
|
||||
|
||||
> 目标:建立 Golden Cases 与性质测试(determinism/monotonic/maxLines)回归体系,保证算法演进可控。
|
||||
|
||||
## 0. 对齐与准备
|
||||
|
||||
- [x] 阅读并对齐口径
|
||||
- [x] 阅读 `spec_kit/Text Wrap/modules/golden-tests/spec.md`
|
||||
- [x] 阅读 `spec_kit/Text Wrap/modules/golden-tests/plan.md`
|
||||
- [x] 阅读 `设计说明文档/文档换行算法.md` 的 16.1/16.2 章节
|
||||
- [x] 创建客户端测试目录
|
||||
- [x] 新建 `client/src/features/textWrap/golden/`
|
||||
|
||||
## 1. Golden fixtures
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/golden/fixtures.ts`
|
||||
- [x] 定义 `GoldenCase` 类型(text/lang/context/availableWidth/maxLines 等)
|
||||
- [x] 补充 EN/TC 基础样例集合(首版最小可运行)
|
||||
- [x] 对每个 case 写入 `expected.lines/expected.wrappedText`
|
||||
|
||||
## 2. 测试 harness(临时串联)
|
||||
|
||||
- [x] 在测试中实现 `wrapTextHarness()`(仅用于测试)
|
||||
- [x] normalizeWhitespace
|
||||
- [x] EN tokenizeEN / TC segmentGraphemes
|
||||
- [x] 使用“全断点集合”(1..N-1,首版避免候选裁剪影响)
|
||||
- [x] APP:searchBestLayoutApp(测量 mock=length)
|
||||
- [x] WIDGET:searchBestLayoutWidget(widthMode=APPROX,单位一致)
|
||||
|
||||
## 3. Golden cases 测试
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/golden/__tests__/golden.test.ts`
|
||||
- [x] 遍历 fixtures,断言输出与 expected 完全一致
|
||||
|
||||
## 4. 性质测试(property tests)
|
||||
|
||||
- [x] 确定性:同输入运行多次输出一致
|
||||
- [x] 近似单调性:availableWidth 变小不会让任一行变得更宽(同测量 mock 下,用 width=string.length)
|
||||
- [x] maxLines 不变差:maxLines 增大时至少不从可解变 overflow(同测量 mock 下)
|
||||
|
||||
## 5. 收尾
|
||||
|
||||
- [x] 跑测试与类型检查
|
||||
- [x] `npm test`
|
||||
- [x] `npx tsc --noEmit`
|
||||
- [x] 将本 `tasks.md` 全部勾选完成
|
||||
- [x] 更新 `spec_kit/overview.md`
|
||||
- [x] 标记 `golden-tests` 已完成(阶段性)
|
||||
- [x] 写入变更文件清单
|
||||
|
||||
136
spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md
Normal file
136
spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# grapheme-segmentation(技术计划)
|
||||
|
||||
## 1. 计划目标
|
||||
|
||||
基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,在客户端侧(JS/TS)落地**可复用且确定性**的 TC grapheme cluster(字符簇)分割能力,用于 Text Wrap 的 TC tokens 生成,确保:
|
||||
|
||||
- 不拆 surrogate pair、ZWJ、VS16、肤色修饰符、国旗(regional indicator flags)、组合字符(如 `é`)
|
||||
- 同输入同输出(clusters 内容与顺序完全一致)
|
||||
- 输出边界可作为 TC 断点边界(后续模块仅在 cluster 边界断行)
|
||||
- 提供可解释 meta(采用何种策略、是否降级)
|
||||
|
||||
## 2. 默认技术决策(本计划采用)
|
||||
|
||||
> 说明:本模块要解决的是“分割口径”,不引入排版/搜索逻辑。
|
||||
|
||||
- **优先策略**:若运行环境支持 `Intl.Segmenter`,优先使用:
|
||||
- `new Intl.Segmenter('zh-Hant', { granularity: 'grapheme' })`
|
||||
- 取其 `segment(text)` 的 `segment` 字段作为 clusters
|
||||
- **兜底策略(两种实现路径,默认选 A)**:
|
||||
- **方案 A(推荐)**:引入轻量依赖 `grapheme-splitter` 作为 fallback,避免手写不完整的 Unicode 规则导致漏拆/误拆
|
||||
- **方案 B(无依赖兜底)**:实现“最低可用”分割(满足文档列出的组合不拆),但不承诺覆盖所有 Unicode 边界规则(风险较高)
|
||||
|
||||
> 本计划默认采用 **方案 A**。若后续明确“禁止新增依赖”,再切换到方案 B 并补齐更多回归。
|
||||
|
||||
## 3. 目录与产物
|
||||
|
||||
本子模块目录:
|
||||
|
||||
- `spec_kit/Text Wrap/modules/grapheme-segmentation/spec.md`
|
||||
- `spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md`(本文)
|
||||
|
||||
建议未来代码落位(实现阶段落地):
|
||||
|
||||
- `client/src/features/textWrap/grapheme/`
|
||||
- `segmentGraphemes.ts`
|
||||
- `strategies/intlSegmenter.ts`
|
||||
- `strategies/fallback.ts`
|
||||
- `__tests__/segmentGraphemes.test.ts`
|
||||
|
||||
## 4. API 设计(实现阶段的稳定契约)
|
||||
|
||||
实现一个最小可用纯函数:
|
||||
|
||||
- `segmentGraphemes(text: string, mode: 'PREFERRED' | 'FALLBACK') => { clusters: string[]; meta: { strategy: 'INTL_SEGMENTER' | 'FALLBACK'; hadFallback: boolean } }`
|
||||
|
||||
设计约束:
|
||||
|
||||
- `clusters.join('') === text`(不允许丢字符/改字符顺序)
|
||||
- `clusters` 为空数组时必须是 `text==''`(不允许把空白当成 cluster 误输出)
|
||||
|
||||
## 5. 实现步骤(按落地顺序)
|
||||
|
||||
### 5.1 优先策略:Intl.Segmenter
|
||||
|
||||
- 检测 `globalThis.Intl?.Segmenter` 是否可用
|
||||
- 若可用且 `mode='PREFERRED'`:
|
||||
- 使用 `granularity='grapheme'` 分割
|
||||
- 输出 `meta.strategy='INTL_SEGMENTER'`,`hadFallback=false`
|
||||
|
||||
注意:
|
||||
|
||||
- 需要确认 Expo/RN 的运行时是否始终具备 `Intl.Segmenter`(不同 JS 引擎/版本可能差异)
|
||||
- 即使可用,也必须通过回归样例验证“不拆”要求
|
||||
|
||||
### 5.2 兜底策略:Fallback
|
||||
|
||||
当出现以下任一情况时进入 fallback:
|
||||
|
||||
- `mode='FALLBACK'`
|
||||
- `Intl.Segmenter` 不存在
|
||||
- `Intl.Segmenter` 运行抛错/返回异常结果(如空、丢字符)
|
||||
|
||||
#### 方案 A:`grapheme-splitter`
|
||||
|
||||
- 新增依赖:`grapheme-splitter`
|
||||
- 使用其分割能力输出 clusters
|
||||
- 输出 `meta.strategy='FALLBACK'`,`hadFallback=true`
|
||||
|
||||
#### 方案 B:最低可用手写规则(仅在禁止依赖时启用)
|
||||
|
||||
实现最低要求:
|
||||
|
||||
- 合并 surrogate pair
|
||||
- 合并 ZWJ sequence(`U+200D` 连接)
|
||||
- 合并 variation selector(如 `U+FE0F`)
|
||||
- 合并 skin tone modifier(`U+1F3FB..U+1F3FF`)
|
||||
- 合并 regional indicator flags(两两成对)
|
||||
- 合并组合字符(combining marks)与预组合等价形式(至少覆盖 `e\u0301`)
|
||||
|
||||
风险提示:
|
||||
|
||||
- 该实现容易漏掉其他扩展 grapheme cluster 规则;需要更高的测试覆盖与持续维护
|
||||
|
||||
## 6. 回归用例与测试计划(Vitest)
|
||||
|
||||
### 6.1 必须覆盖的样例(文档要求)
|
||||
|
||||
以下输入必须“不拆”为单个 cluster:
|
||||
|
||||
- `👨👩👧👦`
|
||||
- `🇸🇬`
|
||||
- `👍🏽`
|
||||
- `😮💨`
|
||||
- `é`(至少覆盖 `e\u0301` 组合形式)
|
||||
|
||||
断言:
|
||||
|
||||
- `clusters.length === 1`
|
||||
- `clusters[0] === input`
|
||||
|
||||
### 6.2 基础性质测试(建议)
|
||||
|
||||
- **可逆性**:`clusters.join('') === input`
|
||||
- **确定性**:同输入多次调用输出完全一致
|
||||
- **空字符串**:`'' -> []`
|
||||
|
||||
### 6.3 跨策略一致性(建议)
|
||||
|
||||
在支持 `Intl.Segmenter` 的环境中:
|
||||
|
||||
- 同一输入在 `PREFERRED` 与 `FALLBACK` 两种模式下输出 clusters 应一致
|
||||
- 若出现差异,必须新增回归样例并明确差异原因(并在上层通过 configVersion 治理)
|
||||
|
||||
## 7. 性能与安全
|
||||
|
||||
- 单次分割复杂度应接近 \(O(n)\)
|
||||
- 对超长文本(例如 > 2000 code units)建议在上层模块触发裁剪或打点(本模块仅保证不崩溃)
|
||||
- 任何异常必须被捕获并降级到 fallback(保证“可用性优先”)
|
||||
|
||||
## 8. 完成定义(DoD)
|
||||
|
||||
- `segmentGraphemes()` 在本地可运行,并通过必测回归样例
|
||||
- 输出 meta 能区分 `INTL_SEGMENTER` 与 `FALLBACK`
|
||||
- 单测覆盖:必测样例 + 可逆性 + 确定性
|
||||
- 文档口径与实现一致(不拆要求、fallback 触发条件、返回结构)
|
||||
|
||||
52
spec_kit/Text Wrap/modules/grapheme-segmentation/spec.md
Normal file
52
spec_kit/Text Wrap/modules/grapheme-segmentation/spec.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# grapheme-segmentation(子模块规范)
|
||||
|
||||
## 子模块名称
|
||||
|
||||
grapheme-segmentation(TC 字符簇分割)
|
||||
|
||||
## 目标描述
|
||||
|
||||
在 TC(中文/繁中)场景下,统一“按 grapheme cluster(字符簇)切分”的实现口径,确保:
|
||||
|
||||
- 不拆分 surrogate pair(代理对)
|
||||
- 不拆分 ZWJ 序列(家庭 emoji 等)
|
||||
- 不拆分 variation selector(VS16 等)
|
||||
- 不拆分 skin tone modifier(肤色修饰符)
|
||||
- 不拆分 regional indicator flags(国旗)
|
||||
- 覆盖组合字符(如 `é`)
|
||||
|
||||
优先使用平台级 segmentation(如 ICU / 系统 API / `Intl.Segmenter`),无库时提供最低可用兜底。
|
||||
|
||||
## 输入/输出定义
|
||||
|
||||
### 输入
|
||||
|
||||
- `text: string`(已完成空白归一化的文本,或原始文本)
|
||||
- `mode: 'PREFERRED' | 'FALLBACK'`
|
||||
|
||||
### 输出
|
||||
|
||||
- `clusters: string[]`
|
||||
- 每个元素为一个 grapheme cluster(用于 TC tokens)
|
||||
- `meta?: { strategy: 'PLATFORM' | 'INTL_SEGMENTER' | 'FALLBACK'; hadFallback: boolean }`
|
||||
|
||||
## 验收标准(可验证)
|
||||
|
||||
至少通过以下回归样例(每个样例都必须“单 token 不拆”):
|
||||
|
||||
- `👨👩👧👦`
|
||||
- `🇸🇬`
|
||||
- `👍🏽`
|
||||
- `😮💨`
|
||||
- `é`
|
||||
|
||||
并满足:
|
||||
|
||||
- **确定性**:同输入同输出(clusters 顺序与内容完全一致)
|
||||
- **边界一致**:任意换行断点只允许发生在 `clusters` 边界
|
||||
|
||||
## 依赖与关联
|
||||
|
||||
- **被依赖**:`core-contract`(TC token 生成)、`breakpoint-candidates`
|
||||
- **不依赖其他模块**
|
||||
|
||||
126
spec_kit/Text Wrap/modules/grapheme-segmentation/tasks.md
Normal file
126
spec_kit/Text Wrap/modules/grapheme-segmentation/tasks.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# grapheme-segmentation(任务清单)
|
||||
|
||||
> 对应计划:`spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md`
|
||||
>
|
||||
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
|
||||
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 任务标记规则
|
||||
|
||||
- 用勾选框标记执行状态:
|
||||
- `[ ]` 未完成
|
||||
- `[x]` 已完成
|
||||
- 每个任务必须可独立验收(有明确产出与检查方式)。
|
||||
- 所有代码注释必须为简体中文,且把“分割口径”写清楚,避免跨端实现漂移。
|
||||
|
||||
---
|
||||
|
||||
## 1. 前置检查(环境能力与策略选择)
|
||||
|
||||
- [x] 1.1 确认运行时是否支持 `Intl.Segmenter`(Expo/RN 当前引擎)
|
||||
- **方式**:在本地运行/测试环境中打印或断言 `globalThis.Intl?.Segmenter` 是否存在
|
||||
- **验收**:记录结论:存在/不存在;若不存在,fallback 必须覆盖所有必测样例。
|
||||
|
||||
- [x] 1.2 确认 fallback 策略选择为“方案 A:`grapheme-splitter`”
|
||||
- **要求**:若项目明确禁止新增依赖,需要在本任务中写明原因并切换到“方案 B(手写最低可用)”,同时补齐更高测试覆盖
|
||||
- **验收**:plan 与实际实现策略一致(不出现“文档写 A、代码做 B”的漂移)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 依赖与目录骨架(客户端侧实现)
|
||||
|
||||
- [x] 2.1 新建目录 `client/src/features/textWrap/grapheme/`
|
||||
- **产出**(建议文件):
|
||||
- `segmentGraphemes.ts`(对外纯函数)
|
||||
- `strategies/intlSegmenter.ts`
|
||||
- `strategies/fallback.ts`
|
||||
- `types.ts`(返回结构与 meta 类型)
|
||||
- `__tests__/segmentGraphemes.test.ts`
|
||||
- **验收**:目录存在,TS 可正常 import(不报路径错误)。
|
||||
|
||||
- [x] 2.2(方案 A)新增依赖 `grapheme-splitter` 并锁定到 `client/package.json`
|
||||
- **验收**:
|
||||
- `npm install grapheme-splitter` 成功
|
||||
- `npm test` 仍能通过(不破坏现有测试)
|
||||
|
||||
---
|
||||
|
||||
## 3. 纯函数实现(分割 + meta)
|
||||
|
||||
- [x] 3.1 实现 `segmentGraphemes(text, mode)` 的返回契约
|
||||
- **要求**:
|
||||
- 返回 `{ clusters, meta }`
|
||||
- `clusters.join('') === text`(不丢字符/不改顺序)
|
||||
- `text==''` 时 `clusters==[]`
|
||||
- **验收**:为上述约束写入单测并通过。
|
||||
|
||||
- [x] 3.2 实现优先策略 `Intl.Segmenter`(`mode='PREFERRED'` 时优先)
|
||||
- **要求**:
|
||||
- `meta.strategy='INTL_SEGMENTER'`
|
||||
- `meta.hadFallback=false`
|
||||
- **验收**:在支持该能力的环境中,至少 1 个常规输入能走到该策略(可通过 meta 断言)。
|
||||
|
||||
- [x] 3.3 实现 fallback 策略(满足所有必测“不拆”样例)
|
||||
- **触发条件**(任一满足即 fallback):
|
||||
- `mode='FALLBACK'`
|
||||
- `Intl.Segmenter` 不存在
|
||||
- `Intl.Segmenter` 抛错/返回异常结果(空/丢字符)
|
||||
- **要求**:
|
||||
- `meta.strategy='FALLBACK'`
|
||||
- `meta.hadFallback=true`
|
||||
- **验收**:必测样例全部通过(见 4.2)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 单元测试(Vitest)
|
||||
|
||||
- [x] 4.1 基础性质测试
|
||||
- **覆盖**:
|
||||
- 可逆性:`clusters.join('') === input`
|
||||
- 确定性:同输入多次调用输出一致
|
||||
- 空字符串:`'' -> []`
|
||||
- **验收**:测试通过且不会出现偶现失败。
|
||||
|
||||
- [x] 4.2 必测回归样例(文档要求:每个都必须“不拆”为 1 个 cluster)
|
||||
- **样例**:
|
||||
- `👨👩👧👦`
|
||||
- `🇸🇬`
|
||||
- `👍🏽`
|
||||
- `😮💨`
|
||||
- `e\u0301`(组合字符形式)
|
||||
- **断言**:
|
||||
- `clusters.length === 1`
|
||||
- `clusters[0] === input`
|
||||
- **验收**:在本地 `npm test` 中稳定通过。
|
||||
|
||||
- [x] 4.3 跨策略一致性测试(在支持 `Intl.Segmenter` 的环境中执行)
|
||||
- **内容**:同一输入在 `PREFERRED` 与强制 `FALLBACK` 下输出 clusters 一致
|
||||
- **验收**:一致;若不一致,必须新增回归样例并在文档中写明差异与治理方式(`configVersion`)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 最终自检清单(合入前)
|
||||
|
||||
- [x] 5.1 `npm test` 通过(包含本模块新增用例)
|
||||
- **验收**:不影响现有测试文件。
|
||||
|
||||
- [x] 5.2 `npx tsc --noEmit` 通过(或项目既有 TS 检查命令通过)
|
||||
- **验收**:无类型错误。
|
||||
|
||||
- [x] 5.3 注释与口径自检(简体中文)
|
||||
- **检查点**:
|
||||
- 明确“字符簇不拆”的边界意义(后续断点仅能在 clusters 边界)
|
||||
- 明确 fallback 触发条件与 meta 含义
|
||||
- **验收**:后续模块开发者只看代码也不会产生歧义。
|
||||
|
||||
---
|
||||
|
||||
## 6. 文档回写(任务清单执行完毕后必须做)
|
||||
|
||||
- [x] 6.1 在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充执行状态
|
||||
- **建议写法**:
|
||||
- 增加一行:`- **已完成编码(阶段性)**:grapheme-segmentation(TC 字符簇分割)`
|
||||
- **验收**:overview 能反映该子模块已完成,便于全局追踪。
|
||||
|
||||
77
spec_kit/Text Wrap/modules/integration/plan.md
Normal file
77
spec_kit/Text Wrap/modules/integration/plan.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# integration(技术计划)
|
||||
|
||||
## 1. 计划目标
|
||||
|
||||
实现对外统一入口 `wrapText()`(纯函数风格),把已完成的子模块串成可复用算法模块,供 Home(APP)与 Widget(WIDGET)调用:
|
||||
|
||||
- 参数口径统一:`lang/context/availableWidth/maxLines/overflowMode/lineMode/configVersion/debug`
|
||||
- 输出统一:`lines[]/wrappedText/meta`,且 meta 可用于治理与 UI 兜底
|
||||
- 当 `meta.fallback_type=SYSTEM_DEFAULT` 时,UI 可选择交给系统排版(算法层仅标记,不擅自改变 UI 行为)
|
||||
|
||||
## 2. 入口签名(与大 spec 对齐)
|
||||
|
||||
实现并导出:
|
||||
|
||||
```ts
|
||||
wrapText({
|
||||
text,
|
||||
lang,
|
||||
availableWidth,
|
||||
maxLines,
|
||||
context,
|
||||
fontSpec?,
|
||||
overflowMode?,
|
||||
lineMode?,
|
||||
constraints?,
|
||||
configVersion?,
|
||||
debug?
|
||||
}) => { lines, wrappedText, meta }
|
||||
```
|
||||
|
||||
## 3. 组装流程(按执行顺序)
|
||||
|
||||
1. **normalizeWhitespace**(core-contract)
|
||||
2. **tokenize**
|
||||
- EN:tokenizeEN
|
||||
- TC:segmentGraphemes → Token[]
|
||||
3. **breakpoint-candidates**
|
||||
- generateBreakpoints(传入 constraints.forbiddenBreakRanges)
|
||||
4. **search**
|
||||
- APP:searchBestLayoutApp(DP+TopK,测量优先)
|
||||
- WIDGET:searchBestLayoutWidget(Beam,默认 APPROX;可选 MEASURE)
|
||||
5. **overflow-fallback**
|
||||
- 当搜索失败或未覆盖到 N:按 overflowMode 执行(ELLIPSIS/CLIP/SYSTEM_DEFAULT)
|
||||
6. **meta 汇总**
|
||||
- breaks(若可得)
|
||||
- scoreTopTerms(debug=true 时)
|
||||
- fallback_type/overflow_type/reason
|
||||
- configVersion
|
||||
|
||||
## 4. 默认值与映射(固定,确定性)
|
||||
|
||||
- `overflowMode`:
|
||||
- APP 默认 `CLIP`
|
||||
- WIDGET 默认 `ELLIPSIS`
|
||||
- `lineMode`:默认 `AUTO`
|
||||
- `configVersion`:默认 `'v1'`(若调用方未传)
|
||||
- WIDGET widthMode:默认 `APPROX`
|
||||
|
||||
## 5. 代码落位(客户端)
|
||||
|
||||
- `client/src/features/textWrap/`
|
||||
- `wrapText.ts`(入口实现)
|
||||
- `types.ts`(对外输入/输出类型)
|
||||
- `index.ts`(统一导出)
|
||||
|
||||
## 6. 测试与回归
|
||||
|
||||
- 将 `golden-tests` 的测试入口从测试 harness 切换为正式 `wrapText()`(期望不变)
|
||||
- 增加 1 个 integration 单测:验证 `SYSTEM_DEFAULT` meta 语义不变
|
||||
|
||||
## 7. 完成定义(DoD)
|
||||
|
||||
- `wrapText()` 可在 APP/WIDGET 两种 context 下运行
|
||||
- 输出 meta 字段可用于 UI 治理与回溯(含 configVersion)
|
||||
- 全量 `npm test` 与 `tsc --noEmit` 通过
|
||||
- overview 更新记录变更文件
|
||||
|
||||
52
spec_kit/Text Wrap/modules/integration/spec.md
Normal file
52
spec_kit/Text Wrap/modules/integration/spec.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# integration(子模块规范)
|
||||
|
||||
## 子模块名称
|
||||
|
||||
integration(Home / Widget 接入)
|
||||
|
||||
## 目标描述
|
||||
|
||||
把 `wrapText()` 模块以一致参数口径接入到两端渲染链路中,并定义 UI 侧对 meta 的处理规则,确保:
|
||||
|
||||
- Home 与 Widget 使用同一套配置/词库/版本号(`configVersion`)
|
||||
- 同场景下输出一致;不同测量能力导致差异时“可解释且可治理”
|
||||
- SYSTEM_DEFAULT 的语义不被 UI 误用
|
||||
|
||||
## 输入/输出定义
|
||||
|
||||
### 输入
|
||||
|
||||
- Home(APP)侧输入:
|
||||
- `text/lang/availableWidth/maxLines/fontSpec/context='APP'`
|
||||
- 可选:`constraints/overflowMode/lineMode/configVersion/debug`
|
||||
- Widget(WIDGET)侧输入:
|
||||
- `text/lang/availableWidth(profile)/maxLines/context='WIDGET'`
|
||||
- 可选:`constraints/overflowMode/configVersion/debug`
|
||||
|
||||
### 输出
|
||||
|
||||
- UI 渲染消费:
|
||||
- `lines[]`:逐行渲染或插入 `\n`
|
||||
- `wrappedText`:用于一次性渲染或日志/缓存
|
||||
- `meta`:用于调试、打点、兜底策略选择
|
||||
|
||||
## 验收标准(可验证)
|
||||
|
||||
- **参数映射一致**:
|
||||
- 两端对 `lang/context/availableWidth/maxLines/overflowMode/lineMode` 的默认值与映射一致
|
||||
- `configVersion` 必须随结果一起回传到 meta 或打点
|
||||
- **UI 兜底语义正确**:
|
||||
- 当 `meta.fallback_type=SYSTEM_DEFAULT` 时:
|
||||
- UI 允许选择“交给系统排版”(例如不插入 `\n` 或忽略 `lines[]`)
|
||||
- 但不改变算法层返回值
|
||||
- **缓存与复用**:
|
||||
- Home 可对同文案同参数缓存 `wrappedText/lines/breaks`
|
||||
- Widget 可对固定 profile 文案缓存结果(避免频繁计算)
|
||||
- **回归可对齐**:
|
||||
- Golden Cases 在 Home 与 Widget(固定 profile)下可跑通并对齐预期
|
||||
|
||||
## 依赖与关联
|
||||
|
||||
- **依赖**:全部核心模块(`core-contract`、`breakpoint-candidates`、`search-engine-*`、`scoring-tiebreak`、`overflow-fallback`、`golden-tests`)
|
||||
- **被依赖**:无(最终落地层)
|
||||
|
||||
63
spec_kit/Text Wrap/modules/integration/tasks.md
Normal file
63
spec_kit/Text Wrap/modules/integration/tasks.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# integration(任务清单)
|
||||
|
||||
> 目标:实现 `wrapText()` 统一入口并串联所有子模块,供 Home/Widget 使用。
|
||||
|
||||
## 0. 对齐与准备
|
||||
|
||||
- [x] 阅读并对齐口径
|
||||
- [x] 阅读 `spec_kit/Text Wrap/modules/integration/spec.md`
|
||||
- [x] 阅读 `spec_kit/Text Wrap/modules/integration/plan.md`
|
||||
- [x] 阅读 `spec_kit/Text Wrap/spec.md` 的对外接口定义
|
||||
|
||||
## 1. 对外类型定义
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/types.ts`
|
||||
- [x] 定义 `WrapTextInput`(与大 spec 对齐)
|
||||
- [x] 定义 `WrapTextMeta`(包含 configVersion/fallback_type/overflow_type/reason/breaks/scoreTopTerms)
|
||||
- [x] 定义 `WrapTextOutput`
|
||||
|
||||
## 2. 实现 wrapText() 入口
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/wrapText.ts`
|
||||
- [x] normalizeWhitespace(固定策略 NORMALIZE)
|
||||
- [x] tokenize:
|
||||
- [x] EN:tokenizeEN
|
||||
- [x] TC:segmentGraphemes → Token[]
|
||||
- [x] generateBreakpoints(接入 forbiddenBreakRanges)
|
||||
- [x] 组装 scoring 的 lexicons:
|
||||
- [x] protectedPhrases 来自 constraints.protectedPhrases(若有)
|
||||
- [x] 搜索:
|
||||
- [x] APP:searchBestLayoutApp(测量 mockable;fontSpec 缺字段报错由 width-measurement 保证)
|
||||
- [x] WIDGET:searchBestLayoutWidget(默认 APPROX;可选 MEASURE)
|
||||
- [x] 搜索失败/未覆盖 N:
|
||||
- [x] 调用 applyOverflowFallback(overflowMode 默认:APP=CLIP,WIDGET=ELLIPSIS)
|
||||
- [x] 汇总输出 meta:
|
||||
- [x] configVersion
|
||||
- [x] breaks(若可得)
|
||||
- [x] scoreTopTerms(debug=true)
|
||||
- [x] fallback_type/overflow_type/reason
|
||||
|
||||
## 3. 导出
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/index.ts`
|
||||
- [x] 导出 types 与 `wrapText`
|
||||
|
||||
## 4. 调整 Golden Tests 入口
|
||||
|
||||
- [x] 将 `client/src/features/textWrap/golden/__tests__/golden.test.ts` 从测试 harness 切换为 `wrapText()`
|
||||
- [x] 保持现有 fixtures 期望不变
|
||||
|
||||
## 5. 单测补充(最小)
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/__tests__/wrapText.integration.test.ts`
|
||||
- [x] SYSTEM_DEFAULT:meta.fallback_type=SYSTEM_DEFAULT 且仍返回 lines/wrappedText
|
||||
|
||||
## 6. 收尾
|
||||
|
||||
- [x] `npm test`
|
||||
- [x] `npx tsc --noEmit`
|
||||
- [x] 将本 `tasks.md` 全部勾选完成
|
||||
- [x] 更新 `spec_kit/overview.md`
|
||||
- [x] 标记 `integration` 已完成(阶段性)
|
||||
- [x] 写入变更文件清单
|
||||
|
||||
109
spec_kit/Text Wrap/modules/overflow-fallback/plan.md
Normal file
109
spec_kit/Text Wrap/modules/overflow-fallback/plan.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# overflow-fallback(技术计划)
|
||||
|
||||
## 1. 计划目标
|
||||
|
||||
实现统一的“溢出与兜底”策略模块,用于在以下情况给出确定性且可解释的返回:
|
||||
|
||||
- 搜索器无法在 `<=maxLines` 覆盖到结束边界 `N`
|
||||
- `lineMode=FIXED` 需要刚好 `maxLines` 但无解
|
||||
- 测量不可用或失败导致无法执行宽度派约束(尤其 Widget)
|
||||
|
||||
支持三种模式:
|
||||
|
||||
- `ELLIPSIS`:最后一行加省略号并保证不超宽(必要时回退移除 token 再加)
|
||||
- `CLIP`:截断到 `maxLines`(不加省略号)
|
||||
- `SYSTEM_DEFAULT`:算法层不插入换行,交给系统排版(仅在 meta 标记)
|
||||
|
||||
## 2. 对外输入/输出(与 spec 对齐)
|
||||
|
||||
### 2.1 输入
|
||||
|
||||
- `tokens: Token[]`
|
||||
- `partialLayout?: { breaks: number[]; lines: Array<{ start; end; text? }> }`
|
||||
- 若提供:表示搜索器的 best-effort(可能未覆盖到 N)
|
||||
- `overflowMode: 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'`
|
||||
- `availableWidth: number`
|
||||
- `maxLines: number`
|
||||
- `lang: 'TC' | 'EN'`
|
||||
- `context: 'APP' | 'WIDGET'`
|
||||
- `ellipsisToken: string`(推荐 `"…"`,配置化)
|
||||
- `measure?: { contextProfile; fontSpec; measureWidthImpl; widgetEnableMeasure? }`(用于“加省略号后再测量”)
|
||||
- `reason: 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'TOO_LONG' | 'WIDOW' | 'PARTICLE' | string`
|
||||
|
||||
### 2.2 输出
|
||||
|
||||
统一返回:
|
||||
|
||||
- `result: { lines: string[]; wrappedText: string; meta: { fallback_type; overflow_type; reason } }`
|
||||
|
||||
其中:
|
||||
|
||||
- `fallback_type`: `NONE | RELAX_RULES | SYSTEM_DEFAULT`
|
||||
- `overflow_type`: `NONE | ELLIPSIS | CLIP`
|
||||
|
||||
## 3. 核心行为细则(对应文档 12.x)
|
||||
|
||||
### 3.1 overflow 判定
|
||||
|
||||
在本模块内不重新跑搜索,仅基于输入判断:
|
||||
|
||||
- 若 `partialLayout` 覆盖到 `N`(即最后一行 `end==N`),则 overflow_type=NONE
|
||||
- 否则为 overflow,按 overflowMode 执行
|
||||
|
||||
### 3.2 SYSTEM_DEFAULT(12.4A)
|
||||
|
||||
- 返回 `lines=[原文单行]`,`wrappedText=原文`
|
||||
- `meta.fallback_type='SYSTEM_DEFAULT'`
|
||||
- `meta.overflow_type='NONE'`(因为不再输出算法换行;由 UI 决定是否完全交给系统)
|
||||
- `meta.reason=输入 reason`
|
||||
|
||||
### 3.3 CLIP
|
||||
|
||||
- 若 `partialLayout` 有 lines:取前 `maxLines` 行,重组 `wrappedText=lines.join('\n')`
|
||||
- 若无:返回单行原文
|
||||
- `meta.overflow_type='CLIP'`,`fallback_type='NONE'`,`reason=输入 reason`
|
||||
|
||||
### 3.4 ELLIPSIS(12.3/12.5)
|
||||
|
||||
仅处理最后一行:
|
||||
|
||||
1. 选取 baseLines:
|
||||
- 优先使用 `partialLayout.lines`(若为空则把全文当单行)
|
||||
- 截断到 `maxLines`(只在最后一行做 ellipsis)
|
||||
2. 清理规则(固定,确定性):
|
||||
- 不输出 `" …"`:最后一行末尾空白先 trim
|
||||
- 不输出 `",…"/"。…"`:若最后一行末尾是常见 TC 标点(`,。!?;:、`),则移除该标点再加 ellipsis
|
||||
3. 宽度检查(12.3):
|
||||
- 若提供测量能力且启用:
|
||||
- 重新测量 `lastLine+ellipsisToken`
|
||||
- 若超宽:按语言回退单位移除 token 再加省略号并重测
|
||||
- EN:按整词(token)
|
||||
- TC:按 grapheme(token)
|
||||
- 若测量不可用:不做重测,直接输出(meta.reason 仍保留)
|
||||
|
||||
> 说明:首版不做“避免截断 emotionPhrase 的整段前移/整段省略”,该策略可在后续结合 scoring 决策升级,但必须保持确定性。
|
||||
|
||||
## 4. 代码落位(客户端)
|
||||
|
||||
- `client/src/features/textWrap/overflow/`
|
||||
- `types.ts`
|
||||
- `ellipsis.ts`
|
||||
- `fallback.ts`(入口:applyOverflowFallback)
|
||||
- `index.ts`
|
||||
- `__tests__/overflowFallback.test.ts`
|
||||
|
||||
## 5. 测试计划(Vitest)
|
||||
|
||||
- SYSTEM_DEFAULT:lines 单行,meta.fallback_type=SYSTEM_DEFAULT
|
||||
- CLIP:超过 maxLines 时截断行数
|
||||
- ELLIPSIS:
|
||||
- 末尾空白去除,不输出 `" …"`
|
||||
- 末尾 TC 标点去除,不输出 `",…"`
|
||||
- 测量超宽时按 token 回退直到不超宽(用 mock measureWidthImpl)
|
||||
|
||||
## 6. 完成定义(DoD)
|
||||
|
||||
- 三种 overflowMode 行为稳定且可解释
|
||||
- ELLIPSIS 的清理与回退测量实现完毕(可测)
|
||||
- 输出 meta 字段满足文档 12/14 的治理需求
|
||||
|
||||
52
spec_kit/Text Wrap/modules/overflow-fallback/spec.md
Normal file
52
spec_kit/Text Wrap/modules/overflow-fallback/spec.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# overflow-fallback(子模块规范)
|
||||
|
||||
## 子模块名称
|
||||
|
||||
overflow-fallback(溢出与兜底)
|
||||
|
||||
## 目标描述
|
||||
|
||||
统一定义“无解/溢出”时的行为与返回语义,确保:
|
||||
|
||||
- APP 与 WIDGET 在无法覆盖全文时表现一致且可解释
|
||||
- ELLIPSIS 的字符、清理规则与再次测量约束统一
|
||||
- SYSTEM_DEFAULT 的返回语义明确(算法层不擅自改变 UI 行为)
|
||||
|
||||
## 输入/输出定义
|
||||
|
||||
### 输入
|
||||
|
||||
- `tokens: Token[]`
|
||||
- `partialBest?: Layout`(搜索器找到的最佳 partial 或 best effort)
|
||||
- `overflowMode: 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'`
|
||||
- `availableWidth: number`
|
||||
- `maxLines: number`
|
||||
- `lang: 'TC' | 'EN'`
|
||||
- `context: 'APP' | 'WIDGET'`
|
||||
- `ellipsisToken: string`(推荐 `"…"`,必须配置化)
|
||||
- `measureWidth?: fn`(用于“加省略号后再测量”)
|
||||
|
||||
### 输出
|
||||
|
||||
- `result: { lines: string[]; wrappedText: string; meta: { fallback_type: 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT'; overflow_type: 'NONE' | 'ELLIPSIS' | 'CLIP'; reason: string } }`
|
||||
|
||||
## 验收标准(可验证)
|
||||
|
||||
- **overflow 判定一致**:
|
||||
- 搜索无法在 `<=maxLines` 覆盖到结束边界 N,即为 overflow
|
||||
- **ELLIPSIS 规则一致**:
|
||||
- 仅处理最后一行
|
||||
- 加省略号后必须再次检查不超宽(必要时回退移除 token 再加省略号)
|
||||
- EN 回退单位为整词;TC 回退单位为 grapheme
|
||||
- 清理规则:不输出 `" …"`;不输出 `",…"/"。…"`(按配置决定是否移除末尾标点再加省略号,但必须固定)
|
||||
- **SYSTEM_DEFAULT 语义一致**:
|
||||
- 仍返回 `lines/wrappedText`
|
||||
- 仅在 meta 标记 `fallback_type=SYSTEM_DEFAULT`
|
||||
- UI 可依据 meta 决定是否完全交给系统排版
|
||||
- **确定性**:同输入同输出(含 meta)
|
||||
|
||||
## 依赖与关联
|
||||
|
||||
- **依赖**:`core-contract`(重组)、`width-measurement`(测量/降级)、`scoring-tiebreak`(避免截断短语的策略可复用评分)
|
||||
- **被依赖**:`search-engine-app`、`search-engine-widget`、`integration`、`golden-tests`
|
||||
|
||||
65
spec_kit/Text Wrap/modules/overflow-fallback/tasks.md
Normal file
65
spec_kit/Text Wrap/modules/overflow-fallback/tasks.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# overflow-fallback(任务清单)
|
||||
|
||||
> 目标:统一实现 `ELLIPSIS/CLIP/SYSTEM_DEFAULT` 的溢出与兜底语义(文档 12.x),输出稳定可解释的 meta。
|
||||
|
||||
## 0. 对齐与准备
|
||||
|
||||
- [x] 阅读并对齐口径
|
||||
- [x] 阅读 `spec_kit/Text Wrap/modules/overflow-fallback/spec.md`
|
||||
- [x] 阅读 `spec_kit/Text Wrap/modules/overflow-fallback/plan.md`
|
||||
- [x] 阅读 `设计说明文档/文档换行算法.md` 的 12.x/14 章节
|
||||
- [x] 创建客户端模块目录
|
||||
- [x] 新建 `client/src/features/textWrap/overflow/`
|
||||
|
||||
## 1. 类型与对外接口
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/overflow/types.ts`
|
||||
- [x] 定义 `OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'`
|
||||
- [x] 定义 `FallbackType = 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT'`
|
||||
- [x] 定义 `OverflowType = 'NONE' | 'ELLIPSIS' | 'CLIP'`
|
||||
- [x] 定义 `OverflowReason`(至少包含:NO_CANDIDATE/WIDTH_UNKNOWN/TOO_LONG/WIDOW/PARTICLE)
|
||||
- [x] 定义 `PartialLayoutInput`:`breaks` + `lines[{start,end,text?}]`
|
||||
- [x] 定义 `ApplyOverflowFallbackInput/Result`
|
||||
- [x] 新建 `client/src/features/textWrap/overflow/index.ts`
|
||||
- [x] 统一导出 types 与入口 `applyOverflowFallback()`
|
||||
|
||||
## 2. ELLIPSIS 核心逻辑
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/overflow/ellipsis.ts`
|
||||
- [x] 实现 `cleanLineBeforeEllipsis(line, lang)`:
|
||||
- [x] trim 末尾空白,避免 `" …"`
|
||||
- [x] TC:若末尾是 `,。!?;:、`,移除该标点,避免 `",…"`
|
||||
- [x] 实现 `applyEllipsisToLastLine(...)`:
|
||||
- [x] 仅处理最后一行
|
||||
- [x] EN 回退单位=整词;TC 回退单位=grapheme
|
||||
- [x] 若提供测量能力:加省略号后必须重新测量,超宽则循环回退再测量
|
||||
- [x] 若测量不可用:直接输出(确定性)
|
||||
|
||||
## 3. 入口:applyOverflowFallback
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/overflow/fallback.ts`
|
||||
- [x] 实现 `applyOverflowFallback(input)`:
|
||||
- [x] overflow 判定:partialLayout 是否覆盖到 N
|
||||
- [x] `SYSTEM_DEFAULT`(12.4A):返回单行原文 + meta.fallback_type=SYSTEM_DEFAULT
|
||||
- [x] `CLIP`:截断到 maxLines
|
||||
- [x] `ELLIPSIS`:调用 ellipsis 逻辑
|
||||
- [x] meta 输出:`fallback_type/overflow_type/reason`
|
||||
|
||||
## 4. 单测(Vitest)
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/overflow/__tests__/overflowFallback.test.ts`
|
||||
- [x] SYSTEM_DEFAULT 语义
|
||||
- [x] CLIP 截断行为
|
||||
- [x] ELLIPSIS 清理规则(不输出 `" …"`、不输出 `",…"`)
|
||||
- [x] ELLIPSIS 测量回退:mock measureWidthImpl,让超宽时按 token 回退直到不超宽
|
||||
|
||||
## 5. 收尾
|
||||
|
||||
- [x] 跑测试与类型检查
|
||||
- [x] `npm test`
|
||||
- [x] `npx tsc --noEmit`
|
||||
- [x] 将本 `tasks.md` 全部勾选完成
|
||||
- [x] 更新 `spec_kit/overview.md`
|
||||
- [x] 标记 `overflow-fallback` 已完成编码(阶段性)
|
||||
- [x] 写入变更文件清单
|
||||
|
||||
179
spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md
Normal file
179
spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# scoring-tiebreak(技术计划)
|
||||
|
||||
## 1. 计划目标
|
||||
|
||||
基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,在客户端实现可解释的评分模型与确定性 tie-break,保证:
|
||||
|
||||
- **整数评分**:所有评分项使用整数,避免浮点误差
|
||||
- **权重写死客户端**:首版权重/词表内置在客户端代码(后续如需灰度,用 `configVersion` 管理)
|
||||
- **短语匹配口径固定**:emotionPhrases/protectedPhrases 必须是“连续 token 完全匹配”
|
||||
- **更长短语优先**:采用方案 A——在触发拆分惩罚时按“被拆分短语长度”追加惩罚(确定性)
|
||||
- **debug 可解释**:输出 score breakdown;debug=true 时输出 Top-3,按规则优先级排序(不是按 |delta|)
|
||||
- **tieKey 固定**:严格按文档第 11 节顺序构造 tieKey;Widget approx mode 仍使用数值 width(可为近似单位),确保 tie-break 可用
|
||||
|
||||
本模块不负责搜索(DP/Beam),只负责对“候选 layout”打分并给出可比较的 tieKey。
|
||||
|
||||
## 2. 默认技术决策(本计划采用)
|
||||
|
||||
### 2.1 评分数值体系
|
||||
|
||||
- 全部评分项使用 **整数**
|
||||
- 不引入 EPS(因为不使用浮点);分数相等即为相等
|
||||
|
||||
### 2.2 权重与默认值(写死客户端)
|
||||
|
||||
首版采用文档 10.3 的建议量级(可在实现中集中定义为常量/配置对象):
|
||||
|
||||
- `P_EMOTION_SPLIT = 10000`
|
||||
- `P_PROTECTED_SPLIT = 10000`
|
||||
- `P_WIDOW_WORD = 800`
|
||||
- `P_WIDOW_LINE = 500`
|
||||
- `P_SHORT_LASTLINE = 300`
|
||||
- `P_PARTICLE_ISO = 200`
|
||||
- `R_PUNCT_BREAK = 80`
|
||||
- `R_SHIFT_BREAK = 60`
|
||||
- `R_ACCUM_BREAK = 40`
|
||||
- `R_SELF_BREAK = 20`
|
||||
- `P_OVER_MAXLEN = 30`
|
||||
- `P_TOO_SHORT = 10`
|
||||
|
||||
> 说明:权重必须集中在一个文件,禁止散落在各函数内;后续调整通过 `configVersion` 记录。
|
||||
|
||||
### 2.3 最小词表(TC/EN)
|
||||
|
||||
首版提供最小可用词表(来源:文档第 7 节):
|
||||
|
||||
- TC:
|
||||
- shift:`但/可是/然而/却/只是/偏偏`
|
||||
- accum:`已经/一直/曾经/终于/还是/到现在`
|
||||
- self:`你/我/自己/我们/别人`
|
||||
- emotionWords(可选):`累/痛/怕/孤单/委屈/撑/崩溃/放弃`
|
||||
- EN(全词等值匹配,使用 core-contract 的 normalize 规则):
|
||||
- shift:`but/yet/so`(and 轻量可选)
|
||||
- accum:`already/still/even/just/really`
|
||||
- self:`you/yourself/me/we`
|
||||
- emotionWords(可选):`tired/afraid/lonely/hurt/overwhelmed/give up`
|
||||
|
||||
emotionPhrasesTC/EN 与 protectedPhrases:
|
||||
|
||||
- 首版允许为空数组(先把匹配与惩罚机制写死)
|
||||
- 若业务侧已有短语清单,后续直接填充并通过 Golden Cases 回归
|
||||
|
||||
### 2.4 宽度派(tieKey 使用 width)
|
||||
|
||||
- layoutCandidate.lines[*].width 字段作为 tieKey 的 width 来源
|
||||
- 在 width unknown/approx mode 时,上游仍需给出“数值宽度”(例如用 wordCount/graphemeCount 作为 width 近似值),保证 tie-break 可执行
|
||||
|
||||
## 3. 目录与产物(客户端侧实现)
|
||||
|
||||
建议代码落位:
|
||||
|
||||
- `client/src/features/textWrap/scoring/`
|
||||
- `types.ts`(Layout/LineSegment/ScoreBreakdown)
|
||||
- `weights.ts`(权重常量,写死)
|
||||
- `lexicons.ts`(最小词表,写死)
|
||||
- `phraseMatch.ts`(连续 token 完全匹配 + span 预计算)
|
||||
- `score.ts`(按 10.1 顺序累计分数)
|
||||
- `tieKey.ts`(按 11 节构造 tieKey)
|
||||
- `debug.ts`(Top-3 提取与排序)
|
||||
- `index.ts`
|
||||
- `__tests__/scoringTiebreak.test.ts`
|
||||
|
||||
## 4. 输入/输出契约(实现阶段写死)
|
||||
|
||||
### 4.1 输入
|
||||
|
||||
- `layoutCandidate`:
|
||||
- `breaks: number[]`
|
||||
- `lines: Array<{ start; end; text; width; tokenCount; charCount }>`
|
||||
- `lang: 'TC' | 'EN'`
|
||||
- `context: 'APP' | 'WIDGET'`
|
||||
- `config`:
|
||||
- `weights: Record<string, number>`(首版由内置默认值生成)
|
||||
- `idealWidthRatio: { APP: 0.90; WIDGET: 0.95 }`(来自文档 8.3A)
|
||||
- `ellipsisToken: "…"`(用于溢出模块;本模块只保留配置入口)
|
||||
- `particleWhitelistTC: string[]`(语尾语助词白名单;惩罚减半)
|
||||
- `lexicons`(首版使用内置最小词表;允许调用方覆盖)
|
||||
|
||||
### 4.2 输出
|
||||
|
||||
- `scoredLayout`:
|
||||
- `score: number`
|
||||
- `flags: { emotionSplit?: boolean; overflowed?: boolean; fallback?: boolean }`
|
||||
- `tieKey: Array<number | string>`
|
||||
- `scoreBreakdown?: { total: number; terms: Array<{ key: string; delta: number; detail?: any }> }`
|
||||
|
||||
## 5. 实现步骤(按落地顺序)
|
||||
|
||||
### 5.1 Phrase 匹配与 span 预计算(连续 token 完全匹配)
|
||||
|
||||
实现口径(必须):
|
||||
|
||||
- EN:对输入 tokens 使用 `core-contract` 的 `normalizeWhitespace + tokenizeEN`
|
||||
- TC:对输入 tokens 使用 `grapheme-segmentation` 的 clusters
|
||||
- 对 phrase:
|
||||
- EN:先 normalizeWhitespace,再按空格切分为词序列(不做 substring)
|
||||
- TC:按 grapheme clusters 切分
|
||||
- 匹配方式:在 token 序列中寻找 **连续区间** 完全匹配
|
||||
- 输出 spans:`{ start, end, length }`(end 为半开区间)
|
||||
|
||||
### 5.2 评分项(严格按 10.1 顺序累计)
|
||||
|
||||
按文档 10.1 固定顺序计算并记录 breakdown:
|
||||
|
||||
1. **情绪短语/受保护短语拆分惩罚**(最高优先)
|
||||
- 若某 phrase span 被 breaks 拆到不同的行:
|
||||
- `score -= P_*`
|
||||
- 并按方案 A 追加惩罚:`score -= phraseLength`(或 `k * phraseLength`,k 写死为 1,确保确定性)
|
||||
- flags:`emotionSplit=true`(或 protectedSplit 也可复用同一 flag/额外字段,需写死)
|
||||
2. **行长度(ideal/over/too short)**
|
||||
- `idealWidth = availableWidth * idealWidthRatio[context]`(availableWidth 由上游传入)
|
||||
- 以 width-based 作为主要项:`score += -abs(line.width - idealWidth)`
|
||||
- overMaxLen/tooShort 按配置阈值与权重惩罚(首版可先按文档默认区间落地)
|
||||
3. **Widow / 单字行惩罚**
|
||||
4. **TC 标点断点奖励**
|
||||
5. **Shift/Accum/Self 断点奖励(含 EN 行首/行尾感知)**
|
||||
6. **尾行过短惩罚(SHORT_LASTLINE)**
|
||||
|
||||
> 注意:必须保留每一项的确定性记录,不允许“覆盖”前序裁决结果(见文档 10.1A)。
|
||||
|
||||
### 5.3 Debug Top-3 输出
|
||||
|
||||
- 输出结构:`ScoreBreakdown = { total, terms[] }`
|
||||
- debug=true 时只保留 Top-3 term,但排序规则必须按优先级(10.1 顺序),同优先级再按出现顺序稳定排序
|
||||
|
||||
### 5.4 tieKey 构造(按 11 节固定顺序)
|
||||
|
||||
按文档顺序生成 tieKey(比较时逐项比较):
|
||||
|
||||
1. `emotionSplit=false` 优先(可用 `0/1`)
|
||||
2. `overflowed=false` 优先
|
||||
3. `lastLineWidth` 更大优先(因此 tieKey 可存 `-lastLineWidth` 或比较时反向)
|
||||
4. 行宽分布更均匀优先(`maxWidth-minWidth` 更小优先)
|
||||
5. 断点更接近理想切分点优先(距离和更小)
|
||||
6. breaks 字典序更靠前优先(使用 core-contract 的 breaks 比较规则)
|
||||
|
||||
## 6. 测试计划(Vitest)
|
||||
|
||||
### 6.1 Phrase 匹配
|
||||
|
||||
- EN:确保全词等值匹配(`but,` 命中 `but`;`rebuttal` 不命中)
|
||||
- TC:emoji/组合字符的 phrase 不被拆(依赖 grapheme-segmentation 已覆盖)
|
||||
|
||||
### 6.2 评分顺序与 breakdown
|
||||
|
||||
- 构造触发 EmotionSplit 的 layout,断言 breakdown 中第一项为 EMOTION_SPLIT(或对应 key),且其记录不会被后续项覆盖
|
||||
|
||||
### 6.3 tieKey 确定性
|
||||
|
||||
- 构造同分 layout,验证 tieKey 顺序能稳定选出同一个最优
|
||||
|
||||
## 7. 完成定义(DoD)
|
||||
|
||||
- 整数评分全链路跑通(无浮点)
|
||||
- phrase 连续 token 完全匹配实现完毕(EN/TC)
|
||||
- scoring 按 10.1 顺序累计,并输出可解释 breakdown
|
||||
- debug Top-3 按优先级输出
|
||||
- tieKey 按 11 节固定顺序构造与比较
|
||||
- 单测覆盖匹配、评分顺序、tieKey 稳定性
|
||||
|
||||
54
spec_kit/Text Wrap/modules/scoring-tiebreak/spec.md
Normal file
54
spec_kit/Text Wrap/modules/scoring-tiebreak/spec.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# scoring-tiebreak(子模块规范)
|
||||
|
||||
## 子模块名称
|
||||
|
||||
scoring-tiebreak(评分模型与确定性裁决)
|
||||
|
||||
## 目标描述
|
||||
|
||||
定义并实现可解释的评分模型(Scoring Model)与固定 tie-break 规则,用于在“合法断点组合”中选出最优布局(layout),并保证跨端完全一致。
|
||||
|
||||
本模块不负责搜索(DP/Beam),但负责:
|
||||
|
||||
- 每行/每断点的评分项计算顺序(必须固定)
|
||||
- 情绪短语/受保护短语的匹配口径(连续 token 完全匹配)
|
||||
- Debug breakdown 的结构与 Top-3 输出排序(按优先级而非 |delta|)
|
||||
- tieKey 的构造与比较规则(含 breaks 字典序定义)
|
||||
|
||||
## 输入/输出定义
|
||||
|
||||
### 输入
|
||||
|
||||
- `layoutCandidate: { breaks: number[]; lines: Array<{ start: number; end: number; text: string; width: number; tokenCount: number; charCount: number }> }`
|
||||
- `lang: 'TC' | 'EN'`
|
||||
- `context: 'APP' | 'WIDGET'`
|
||||
- `config: { weights: Record<string, number>; idealWidthRatio: { APP: number; WIDGET: number }; ellipsisToken: string; particleWhitelistTC: string[]; eps?: number }`
|
||||
- `lexicons: { emotionPhrasesTC: string[]; emotionPhrasesEN: string[]; protectedPhrases?: string[]; shiftWordsTC: string[]; shiftWordsEN: string[]; accumWordsTC: string[]; accumWordsEN: string[]; selfWordsTC: string[]; selfWordsEN: string[]; emotionWordsTC?: string[]; emotionWordsEN?: string[] }`
|
||||
|
||||
### 输出
|
||||
|
||||
- `scoredLayout: { score: number; flags: { emotionSplit?: boolean; overflowed?: boolean; fallback?: boolean }; tieKey: Array<number | string>; scoreBreakdown?: { total: number; terms: Array<{ key: string; delta: number; detail?: any }> } }`
|
||||
|
||||
## 验收标准(可验证)
|
||||
|
||||
- **评分项顺序固定**:评分必须严格按算法文档 10.1 的顺序计算并记录(不可重排)
|
||||
- **短语匹配口径正确**:
|
||||
- emotionPhrase/protectedPhrases:连续 token 完全匹配(不允许跳 token、不允许模糊)
|
||||
- EN 关键词命中遵循 `core-contract` 的“全词等值匹配”
|
||||
- **冲突裁决一致**:
|
||||
- emotionPhrases 与 protectedPhrases 同级强保护
|
||||
- 同等条件下更长短语优先(需在惩罚或 tieKey 中体现,且确定性)
|
||||
- **奖励折减规则一致**:
|
||||
- EmotionWord 与 Accum 同时命中时:Accum 奖励按 0.5 倍(先算 EmotionWord 再折减)
|
||||
- **tie-break 一致**:
|
||||
- 规则顺序固定(emotionSplit/overflowed/lastLineWidth/均衡度/理想距离/breaks 字典序)
|
||||
- breaks 字典序定义:逐项比较;公共前缀相同则更短数组更小
|
||||
- **debug Top-3 输出排序一致**:
|
||||
- debug 模式关键项按优先级输出(非 |delta|)
|
||||
- **整数分优先**:尽量使用整数评分;若使用浮点必须定义 EPS,并用 `abs(a-b)<=EPS` 判断近似相等
|
||||
|
||||
## 依赖与关联
|
||||
|
||||
- **依赖**:`core-contract`(索引、文本重组、EN 命中工具)
|
||||
- **被依赖**:`search-engine-app`、`search-engine-widget`、`overflow-fallback`、`golden-tests`
|
||||
|
||||
122
spec_kit/Text Wrap/modules/scoring-tiebreak/tasks.md
Normal file
122
spec_kit/Text Wrap/modules/scoring-tiebreak/tasks.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# scoring-tiebreak(任务清单)
|
||||
|
||||
> 目标:实现“评分模型 + 确定性 tie-break”,用于对候选 layout 打分并产出可比较的 `tieKey`。
|
||||
> 约束:**整数评分**、**权重/最小词表写死客户端**、短语匹配为**连续 token 完全匹配**、debug Top-3 按**优先级顺序**输出。
|
||||
|
||||
## 0. 准备与对齐
|
||||
|
||||
- [x] 阅读并对齐口径
|
||||
- [x] 复核 `spec_kit/Text Wrap/modules/scoring-tiebreak/spec.md`
|
||||
- [x] 复核 `spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md`
|
||||
- [x] 复核 `设计说明文档/文档换行算法.md` 中 10.x(评分)与 11(tie-break)章节的条目顺序
|
||||
- [x] 在客户端创建模块目录
|
||||
- [x] 新建 `client/src/features/textWrap/scoring/`
|
||||
- [x] 确认后续文件均落在该目录下,避免与 `core/`、`breakpoints/`、`measure/` 混放
|
||||
|
||||
## 1. 定义类型与对外接口
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/scoring/types.ts`
|
||||
- [x] 定义 `TextWrapContext = 'APP' | 'WIDGET'`
|
||||
- [x] 定义 `LayoutCandidate`(与 spec 一致:`breaks` + `lines[]`)
|
||||
- [x] 定义 `LineInfo`:`start/end/text/width/tokenCount/charCount`
|
||||
- [x] 定义 `ScoreTerm`:`{ key: string; delta: number; detail?: any }`
|
||||
- [x] 定义 `ScoreBreakdown`:`{ total: number; terms: ScoreTerm[] }`
|
||||
- [x] 定义 `ScoredLayout`:`{ score; flags; tieKey; scoreBreakdown? }`
|
||||
- [x] 明确评分函数需要的“可用宽度 availableWidth”传入方式(二选一,必须确定并全链路一致)
|
||||
- [x] 方案 A:作为 `scoreLayout({ availableWidth, ... })` 的必填字段
|
||||
- [x] 方案 B:作为 `config.availableWidth`(不推荐,但允许)
|
||||
- [x] 新建 `client/src/features/textWrap/scoring/index.ts`
|
||||
- [x] 统一导出 types 与核心函数(后续实现)
|
||||
|
||||
## 2. 权重与最小词表(写死客户端)
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/scoring/weights.ts`
|
||||
- [x] 以对象形式集中定义权重常量(全部整数)
|
||||
- [x] 写入 plan.md 里的首版默认值(P_/R_ 系列)
|
||||
- [x] 提供 `DEFAULT_WEIGHTS`(只读)与可选的 `mergeWeights(overrides)`(用于调用方覆盖)
|
||||
- [x] 新建 `client/src/features/textWrap/scoring/lexicons.ts`
|
||||
- [x] 定义 `Lexicons` 类型(与 spec 输入一致)
|
||||
- [x] 写入最小词表(TC/EN:shift/accum/self,emotionWords 可选)
|
||||
- [x] 提供 `DEFAULT_LEXICONS`(只读)与可选的 `mergeLexicons(overrides)`
|
||||
- [x] 明确 `emotionPhrasesTC/EN`、`protectedPhrases` 首版可为空数组
|
||||
|
||||
## 3. Phrase 匹配(连续 token 完全匹配)
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/scoring/phraseMatch.ts`
|
||||
- [x] 定义 `PhraseSpan = { start: number; end: number; length: number }`(end 半开)
|
||||
- [x] 实现 EN phrase 预处理
|
||||
- [x] 使用 `core-contract`:`normalizeWhitespace` + `tokenizeEN`
|
||||
- [x] phrase 输入为原始字符串:先 normalizeWhitespace,再按空格切分为词序列
|
||||
- [x] 实现 TC phrase 预处理
|
||||
- [x] 使用 `grapheme-segmentation`:`segmentGraphemes` 得到 clusters
|
||||
- [x] phrase 输入为原始字符串:按 grapheme clusters 切分
|
||||
- [x] 实现连续区间完全匹配查找(禁止跳 token / 禁止模糊)
|
||||
- [x] 输出所有命中的 spans(稳定顺序:按 start 升序,start 相同按 length 降序)
|
||||
- [x] 实现 `isSpanSplitByBreaks(span, breaks)`:判断 span 是否跨行(被拆分)
|
||||
- [x] 单测覆盖:
|
||||
- [x] EN:`but,` 命中 `but`;`rebuttal` 不命中 `but`
|
||||
- [x] TC:包含 emoji/组合字符时不应被拆(依赖 grapheme 模块;这里只验证匹配结果可逆)
|
||||
|
||||
## 4. 评分实现(按 10.1 固定顺序,整数累计)
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/scoring/score.ts`
|
||||
- [x] 定义 `scoreLayout(args)`(入参包含:layoutCandidate、lang、context、config、lexicons、availableWidth、debug?)
|
||||
- [x] 实现 score 累计(必须严格按文档 10.1 的顺序)
|
||||
- [x] 1) 情绪短语/受保护短语拆分惩罚(最高优先)
|
||||
- [x] 匹配 emotionPhrases + protectedPhrases spans
|
||||
- [x] 若 span 被拆分:`score -= P_*`
|
||||
- [x] 采用方案 A:追加“更长短语优先”惩罚:`score -= phraseLength`(k=1 写死)
|
||||
- [x] 设置 `flags.emotionSplit=true`(如需区分 protected 可扩展 detail,但保持确定性)
|
||||
- [x] 2) 行长度(ideal/overMaxLen/tooShort)
|
||||
- [x] `idealWidth = availableWidth * idealWidthRatio[context]`(取整规则必须固定:建议 `Math.round` 并写注释)
|
||||
- [x] width-based 主项:`score += -abs(line.width - idealWidth)`(确保整数)
|
||||
- [x] over/tooShort 按权重惩罚(阈值若来自文档,集中定义常量,禁止散落)
|
||||
- [x] 3) Widow / 单字行惩罚(基于 tokenCount/charCount 口径写死)
|
||||
- [x] 4) TC 标点断点奖励(使用断点 meta/kind 或基于行首字符推断;口径写注释)
|
||||
- [x] 5) Shift/Accum/Self 断点奖励(TC/EN 词表)
|
||||
- [x] EN 必须使用 `core-contract` 的全词等值匹配(复用 `normalizeENKeyword/matchENKeyword`)
|
||||
- [x] 实现 spec 要求的折减:EmotionWord 与 Accum 同时命中时,Accum 奖励按 0.5 倍
|
||||
- [x] 因为整体使用整数:折减采用“整除/四舍五入”的固定规则(建议 `Math.floor(reward/2)` 并写注释)
|
||||
- [x] 6) 尾行过短惩罚(SHORT_LASTLINE)
|
||||
- [x] 记录 `scoreBreakdown`
|
||||
- [x] 每个 term 写入 `{ key, delta, detail }`
|
||||
- [x] terms 的产生顺序必须与 10.1 顺序一致(用于 debug 输出排序)
|
||||
- [x] debug Top-3(不按 |delta|,按优先级)
|
||||
- [x] 若 `debug=true`:只保留前 3 个“优先级最高的关键项”
|
||||
- [x] 规则:先按 10.1 顺序,必要时同优先级保持稳定(按出现顺序)
|
||||
- [x] 单测覆盖:
|
||||
- [x] 评分顺序固定:构造 layout 触发 EmotionSplit,断言 breakdown 第一项为对应 key
|
||||
- [x] 整数性:断言所有 delta 与 total 都是整数
|
||||
|
||||
## 5. tieKey 构造与比较(按 11 节固定顺序)
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/scoring/tieKey.ts`
|
||||
- [x] 实现 `buildTieKey(scoredLayout, layoutCandidate, config)`,严格按 11 节顺序产出 `Array<number | string>`
|
||||
- [x] emotionSplit(false 优先)
|
||||
- [x] overflowed(false 优先)
|
||||
- [x] lastLineWidth(更大优先:采用 `-lastLineWidth` 进入 tieKey,或在比较函数中反向比较,二选一且写注释)
|
||||
- [x] 宽度分布均衡度:`maxWidth - minWidth`(更小优先)
|
||||
- [x] 与理想切分点距离(更小优先;距离定义需与 breakpoints 模块/上游一致)
|
||||
- [x] breaks 字典序(复用 `core-contract` 的 breaks 比较函数)
|
||||
- [x] 单测覆盖:
|
||||
- [x] 同分 layout:通过 tieKey 能稳定选出同一个最优
|
||||
- [x] Widget approx:即使 width 近似(wordCount/graphemeCount),tieKey 仍可比较(不抛错)
|
||||
|
||||
## 6. 组合导出与回归测试
|
||||
|
||||
- [x] 完成 `client/src/features/textWrap/scoring/index.ts` 导出
|
||||
- [x] 导出 `DEFAULT_WEIGHTS/DEFAULT_LEXICONS`
|
||||
- [x] 导出 `scoreLayout`、`buildTieKey`
|
||||
- [x] 新建 `client/src/features/textWrap/scoring/__tests__/scoringTiebreak.test.ts`
|
||||
- [x] 覆盖 EN/TC 两套 tokenization(EN 用 core,TC 用 grapheme)
|
||||
- [x] 覆盖短语拆分惩罚 + 方案 A 的“更长短语更重惩罚”
|
||||
- [x] 覆盖 debug Top-3 按优先级输出(非 |delta|)
|
||||
- [x] 覆盖 tieKey 顺序项的比较方向(尤其 lastLineWidth)
|
||||
|
||||
## 7. 文档与收尾(完成后必须做)
|
||||
|
||||
- [x] 将 `spec_kit/Text Wrap/modules/scoring-tiebreak/tasks.md` 全部任务勾选完成
|
||||
- [x] 更新 `spec_kit/overview.md`
|
||||
- [x] 标记 `scoring-tiebreak` 已完成编码(阶段性)
|
||||
- [x] 在 overview 中记录本次变更文件清单(至少包含新增的客户端文件与测试文件)
|
||||
|
||||
104
spec_kit/Text Wrap/modules/search-engine-app/plan.md
Normal file
104
spec_kit/Text Wrap/modules/search-engine-app/plan.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# search-engine-app(技术计划)
|
||||
|
||||
## 1. 计划目标
|
||||
|
||||
在 **APP** 场景实现确定性的“断点组合搜索器”,使用 **DP + TopK** 生成候选 layout 并选出最优解:
|
||||
|
||||
- 输入:`tokens[]`、`breakpoints[]`、`availableWidth`、`maxLines`、`lineMode`
|
||||
- 过程:DP 状态转移 + TopK 维护(去重、排序、确定性 tie-break)
|
||||
- 评分:调用 `scoring-tiebreak`(`scoreLayout` + `buildTieKey`)
|
||||
- 输出:`bestLayout`(breaks/lines/wrappedText/meta)或返回 “无解/超长/宽度不可用” 的可解释原因(overflow-fallback 模块将统一处理最终语义)
|
||||
|
||||
## 2. 约束与确定性口径
|
||||
|
||||
### 2.1 确定性(必须)
|
||||
|
||||
同输入(含 configVersion/权重/词表/断点列表)必须同输出:
|
||||
|
||||
- DP 遍历顺序固定(pos 升序、linesUsed 升序)
|
||||
- nextPos 枚举顺序固定(按 `pos` 升序;最后补一个 `N` 结束边界)
|
||||
- TopK 的排序固定:`score` 降序;再按 `tieKey` 逐项比较(11 节);再按 `breaks` 字典序(11.0A)
|
||||
- 去重固定:同 `breaks` 序列只保留最优(score 更大;若相同用 tieKey)
|
||||
|
||||
### 2.2 复杂度(必须可控)
|
||||
|
||||
遵循文档 9.3 建议:
|
||||
|
||||
- 默认 `TopK=10`
|
||||
- 默认 `maxLines<=3`(或 4)
|
||||
- 超长输入触发 `TOO_LONG`:TC>60 grapheme 或 EN>30 words(首版写死,后续 configVersion 管理)
|
||||
|
||||
## 3. DP + TopK 设计(对应文档 9.1)
|
||||
|
||||
### 3.1 状态定义
|
||||
|
||||
令 `N=tokens.length`,token 边界为 `pos in [0..N]`:
|
||||
|
||||
- `dp[pos][linesUsed] = TopK partial layouts ending at token boundary pos`
|
||||
- partial layout 至少包含:
|
||||
- `pos`:当前结束位置
|
||||
- `breaks: number[]`:已选择的断点序列(升序)
|
||||
- `lines: LineInfo[]`:已生成的行信息(start/end/text/width/tokenCount/charCount)
|
||||
- `score: number` + `tieKey: number[]`(由 scoring-tiebreak 构造)
|
||||
|
||||
### 3.2 转移(生成下一行)
|
||||
|
||||
从状态 `(pos, linesUsed)` 选择 `nextPos` 生成新行区间 `[pos..nextPos)`:
|
||||
|
||||
- nextPos 取值来自 `breakpoints` 中 `b.pos` 且 `b.pos > pos`,并按 `pos` 升序枚举
|
||||
- 必须额外允许 `nextPos = N`(结束边界)
|
||||
- 每次转移需:
|
||||
- 构造 lineText:使用 `core-contract/joinTokens`
|
||||
- 测量 lineWidth:优先使用 `width-measurement/measureSliceWidthCached`(切片缓存),若不可用返回 `WIDTH_UNKNOWN`
|
||||
- 应用硬约束过滤(见 3.3)
|
||||
- 计算新 layout 的评分与 tieKey(调用 scoring-tiebreak)
|
||||
- 插入 `dp[nextPos][linesUsed+1]` 并维护 TopK(去重、排序)
|
||||
|
||||
### 3.3 硬约束(本模块先落地最小集)
|
||||
|
||||
为了让 DP 行为稳定且可解释,首版在搜索层执行以下硬约束(其余放到评分):
|
||||
|
||||
- **H1 不超宽**:若 lineWidth 可用且 `lineWidth > availableWidth`,该转移无效
|
||||
- **H5 最大行数**:`linesUsed+1 <= maxLines`
|
||||
- **禁止空行**:`nextPos > pos`(区间非空)
|
||||
- **TC 禁止行首标点(H6)**:
|
||||
- 若 `lang=TC` 且 `pos>0` 且 `tokens[pos]` 属于 `tcPunctuations`,则该断点不可用(跳过)
|
||||
|
||||
> 说明:protectedPhrases 的“span 内断点剔除”属于候选断点优化(可选),首版不在搜索层做剔除,依赖 scoring 的强惩罚淘汰(文档 6.2A / 10.2A)。
|
||||
|
||||
### 3.4 结束条件与 lineMode
|
||||
|
||||
- 正常结束:到达 `pos=N`
|
||||
- `lineMode=AUTO`:
|
||||
- 从 `dp[N][1..maxLines]` 里选最优
|
||||
- `lineMode=FIXED`:
|
||||
- 优先从 `dp[N][==maxLines]` 里选最优
|
||||
- 若无解:返回 “无解” 的原因与必要 meta(由 overflow-fallback 统一降级链路与打点)
|
||||
|
||||
## 4. 代码落位(客户端)
|
||||
|
||||
建议目录:
|
||||
|
||||
- `client/src/features/textWrap/searchApp/`
|
||||
- `types.ts`
|
||||
- `dpTopK.ts`(DP 主流程 + TopK 维护)
|
||||
- `topK.ts`(去重、插入、排序、截断)
|
||||
- `constraints.ts`(H1/H6/TOO_LONG 判定)
|
||||
- `index.ts`
|
||||
- `__tests__/searchApp.test.ts`
|
||||
|
||||
## 5. 测试计划(Vitest)
|
||||
|
||||
- **确定性**:同输入运行 3 次输出完全一致(breaks/lines/wrappedText)
|
||||
- **TopK 去重**:构造两条路径得到相同 breaks,断言只保留一个且为最优
|
||||
- **lineMode=FIXED**:有解时必须返回刚好 `maxLines`;无解时返回 reason(例如 `NO_CANDIDATE`)
|
||||
- **TC 行首标点**:构造 tokens 在断点后行首为 `,`,断言该转移被禁止
|
||||
- **TOO_LONG**:TC>60 或 EN>30 时直接返回 `TOO_LONG`
|
||||
|
||||
## 6. 完成定义(DoD)
|
||||
|
||||
- DP+TopK 可运行,且确定性通过单测
|
||||
- 使用 `scoring-tiebreak` 进行评分与 tieKey 裁决
|
||||
- 搜索层硬约束最小集落地(H1/H6/空行/maxLines)
|
||||
- 无解/超长/宽度不可用时返回明确 reason(不直接在本模块做最终 overflow)
|
||||
|
||||
51
spec_kit/Text Wrap/modules/search-engine-app/spec.md
Normal file
51
spec_kit/Text Wrap/modules/search-engine-app/spec.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# search-engine-app(子模块规范)
|
||||
|
||||
## 子模块名称
|
||||
|
||||
search-engine-app(APP 搜索器:DP + TopK)
|
||||
|
||||
## 目标描述
|
||||
|
||||
在 APP 场景实现多行换行的确定性组合搜索:
|
||||
|
||||
- 使用 DP + TopK 生成候选 layout
|
||||
- 对每个候选应用硬约束过滤
|
||||
- 调用 `scoring-tiebreak` 计算评分与 tieKey
|
||||
- 保留 TopK 并在结束时选最优
|
||||
|
||||
要求性能可控且输出完全确定性。
|
||||
|
||||
## 输入/输出定义
|
||||
|
||||
### 输入
|
||||
|
||||
- `tokens: Token[]`
|
||||
- `breakpoints: Breakpoint[]`(来自 `breakpoint-candidates`)
|
||||
- `availableWidth: number`
|
||||
- `maxLines: number`
|
||||
- `lineMode: 'AUTO' | 'FIXED'`
|
||||
- `context: 'APP'`
|
||||
- `measure: { measureWidth?: fn; fontSpec?: any; cache: ... }`(来自 `width-measurement` 的实现)
|
||||
- `config: { topK: number }`
|
||||
|
||||
### 输出
|
||||
|
||||
- `bestLayout: { breaks: number[]; lines: string[]; wrappedText: string; meta?: { breaks: number[]; scoreTopTerms?: ... } }`
|
||||
- 若无解:返回可解释的 fallback(由 `overflow-fallback` 统一处理语义)
|
||||
|
||||
## 验收标准(可验证)
|
||||
|
||||
- **确定性**:同输入(tokens/breakpoints/availableWidth/maxLines/configVersion)必定同输出(breaks 与 lines 完全一致)
|
||||
- **TopK 维护一致**:
|
||||
- K 默认 10(可配置但必须固定)
|
||||
- 排序:score desc;tieKey 逐项比较
|
||||
- 去重:同 breaks 序列只保留最高分
|
||||
- **lineMode=FIXED 行为**:
|
||||
- 优先选刚好 `maxLines` 的解;无解时按固定降级链路处理并打点
|
||||
- **复杂度可控**:超长输入触发裁剪/兜底并返回 reason(如 `TOO_LONG`)
|
||||
|
||||
## 依赖与关联
|
||||
|
||||
- **依赖**:`core-contract`、`width-measurement`、`breakpoint-candidates`、`scoring-tiebreak`、`overflow-fallback`
|
||||
- **被依赖**:`integration`、`golden-tests`
|
||||
|
||||
92
spec_kit/Text Wrap/modules/search-engine-app/tasks.md
Normal file
92
spec_kit/Text Wrap/modules/search-engine-app/tasks.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# search-engine-app(任务清单)
|
||||
|
||||
> 目标:在 APP 场景实现 `DP + TopK` 的确定性组合搜索(对应文档 9.1),并接入 `scoring-tiebreak` 输出最优 layout。
|
||||
|
||||
## 0. 对齐与准备
|
||||
|
||||
- [x] 复核输入/输出与依赖
|
||||
- [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-app/spec.md`
|
||||
- [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-app/plan.md`
|
||||
- [x] 阅读 `设计说明文档/文档换行算法.md` 的 9.1/9.3/11 章节
|
||||
- [x] 创建客户端模块目录
|
||||
- [x] 新建 `client/src/features/textWrap/searchApp/`
|
||||
|
||||
## 1. 类型定义与导出
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/searchApp/types.ts`
|
||||
- [x] 定义 `LineMode = 'AUTO' | 'FIXED'`
|
||||
- [x] 定义 `SearchAppConfig`(包含 `topK`、`tooLongThresholds` 等,首版给默认值)
|
||||
- [x] 定义 `SearchAppInput`:
|
||||
- [x] `tokens: Token[]`
|
||||
- [x] `lang: 'TC' | 'EN'`
|
||||
- [x] `breakpoints: Breakpoint[]`
|
||||
- [x] `availableWidth: number`
|
||||
- [x] `maxLines: number`
|
||||
- [x] `lineMode: LineMode`
|
||||
- [x] `measure: { contextProfile; fontSpec; measureWidthImpl }`(复用 width-measurement 类型)
|
||||
- [x] `scoring: { config; lexicons; debug? }`(复用 scoring-tiebreak 类型)
|
||||
- [x] 定义 `SearchAppResult`:
|
||||
- [x] 成功:`bestLayout { breaks; lines; wrappedText; meta }`
|
||||
- [x] 失败:`{ ok:false; reason:'TOO_LONG'|'WIDTH_UNKNOWN'|'NO_CANDIDATE'; meta }`
|
||||
- [x] 新建 `client/src/features/textWrap/searchApp/index.ts`
|
||||
- [x] 统一导出 types 与核心入口 `searchBestLayoutApp()`
|
||||
|
||||
## 2. TopK 维护(确定性)
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/searchApp/topK.ts`
|
||||
- [x] 实现 `compareLayouts(a,b)`:
|
||||
- [x] `score` 降序
|
||||
- [x] `tieKey` 逐项比较(数值越小越优)
|
||||
- [x] `breaks` 字典序兜底(11.0A)
|
||||
- [x] 实现去重:同 breaks 仅保留最优
|
||||
- [x] 实现 `insertTopK(list, cand, K)`:插入、去重、排序、截断(全程确定性)
|
||||
|
||||
## 3. 约束与可解释失败原因
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/searchApp/constraints.ts`
|
||||
- [x] 实现 `isTooLong(tokens, lang)`:TC>60 或 EN>30(首版写死)
|
||||
- [x] 实现 `isLineStartPunctTC(tokens, pos, tcPunctuations)`:H6
|
||||
- [x] 实现 `isOverWidth(width, availableWidth)`:H1
|
||||
|
||||
## 4. DP + TopK 主流程
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/searchApp/dpTopK.ts`
|
||||
- [x] 实现入口 `searchBestLayoutApp(input)`
|
||||
- [x] 构造 dp:`dp[pos][linesUsed] = TopK[]`
|
||||
- [x] 枚举顺序固定:
|
||||
- [x] pos:0..N
|
||||
- [x] linesUsed:0..maxLines-1
|
||||
- [x] nextPos:从 breakpoints 里取 `>pos` 的 pos 升序 + 追加 `N`
|
||||
- [x] 每次转移:
|
||||
- [x] 构造 lineText:使用 `joinTokens`(EN 默认空格,TC 使用 rawSeparators 拼接)
|
||||
- [x] 测量 lineWidth:使用 `measureSliceWidthCached`
|
||||
- [x] 过滤硬约束:H1/空行/H6/maxLines
|
||||
- [x] 构造 layoutCandidate(breaks + lines[])
|
||||
- [x] 调用 `scoreLayout` + `buildTieKey` 得到 score/tieKey
|
||||
- [x] 插入目标 dp 并维护 TopK
|
||||
- [x] 结束选择:
|
||||
- [x] AUTO:从 `dp[N][<=maxLines]` 选最优
|
||||
- [x] FIXED:优先 `dp[N][==maxLines]`,否则返回 `NO_CANDIDATE`(交给 overflow-fallback 再降级)
|
||||
- [x] meta 输出:
|
||||
- [x] `breaks`
|
||||
- [x] `scoreTopTerms`(若 debug=true,从 scoring 输出 Top-3)
|
||||
|
||||
## 5. 单测(Vitest)
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/searchApp/__tests__/searchApp.test.ts`
|
||||
- [x] 确定性:同输入运行多次结果一致
|
||||
- [x] TopK 去重:两条路径同 breaks 只保留一个
|
||||
- [x] FIXED:必须刚好 maxLines,否则失败 reason=NO_CANDIDATE
|
||||
- [x] TC H6:行首标点导致的转移必须被禁止
|
||||
- [x] TOO_LONG:触发阈值直接返回 reason=TOO_LONG
|
||||
|
||||
## 6. 收尾
|
||||
|
||||
- [x] 跑测试与类型检查
|
||||
- [x] `npm test`
|
||||
- [x] `npx tsc --noEmit`
|
||||
- [x] 将本 `tasks.md` 全部勾选完成
|
||||
- [x] 更新 `spec_kit/overview.md`
|
||||
- [x] 标记 `search-engine-app` 已完成编码(阶段性)
|
||||
- [x] 写入变更文件清单
|
||||
|
||||
115
spec_kit/Text Wrap/modules/search-engine-widget/plan.md
Normal file
115
spec_kit/Text Wrap/modules/search-engine-widget/plan.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# search-engine-widget(技术计划)
|
||||
|
||||
## 1. 计划目标
|
||||
|
||||
在 **WIDGET** 场景实现确定性的 Beam Search(文档 9.2):
|
||||
|
||||
- 每一行扩展时只保留 TopK partial layouts(beam)
|
||||
- 每步扩展断点数 M 受控(更强裁剪)
|
||||
- 支持 `widthMode='MEASURE' | 'APPROX'`
|
||||
- 结束时从 `pos==N` 的 beams 中选最优;若无解返回可解释的失败原因(最终由 `overflow-fallback` 统一语义)
|
||||
|
||||
## 2. 确定性与性能约束
|
||||
|
||||
### 2.1 确定性(必须)
|
||||
|
||||
- beams 的遍历顺序固定(按当前 beams 排序后的顺序)
|
||||
- nextPos 候选顺序固定(`pos` 升序)
|
||||
- pruning 固定(先按候选优先级/位置排序,再取前 M)
|
||||
- beam 的 TopK 维护固定:`score` 降序;再 `tieKey`;再 `breaks` 字典序
|
||||
|
||||
### 2.2 性能参数(写死默认值)
|
||||
|
||||
来自文档 9.3:
|
||||
|
||||
- `beamK=5`
|
||||
- `expandM=12`
|
||||
|
||||
## 3. Beam Search 设计(对应文档 9.2)
|
||||
|
||||
令 `N=tokens.length`,token 边界为 `pos in [0..N]`。
|
||||
|
||||
### 3.1 beam 状态
|
||||
|
||||
一个 beam(partial layout)包含:
|
||||
|
||||
- `pos`:当前已覆盖到的 token 边界
|
||||
- `breaks: number[]`
|
||||
- `lines: LineInfo[]`
|
||||
- `score` + `tieKey`
|
||||
- `meta`(可选):debug Top-3 terms、approx reason 等
|
||||
|
||||
### 3.2 扩展 nextPos(每步最多 M)
|
||||
|
||||
对每个 beam,从 `pos` 扩展到若干 `nextPos`:
|
||||
|
||||
- nextPos 来源:`breakpoints[].pos > pos`(按 pos 升序)
|
||||
- 额外包含 `nextPos=N`(结束边界)
|
||||
- 为控制复杂度:对候选 nextPos 进行裁剪,仅保留前 `expandM`
|
||||
|
||||
裁剪规则(确定性,首版简单可控):
|
||||
|
||||
- 先取 `nextPos` 最小的 `expandM` 个(最短前缀优先,利于早结束)
|
||||
|
||||
> 注:后续可升级为“优先级 + 距理想切分点近”裁剪,但首版以确定性与稳定为主。
|
||||
|
||||
### 3.3 宽度模式
|
||||
|
||||
- `widthMode='MEASURE'`:
|
||||
- 若提供测量能力:使用 `measureSliceWidthCached` 获取 lineWidth
|
||||
- 若测量不可用/失败:降级为 approx(记录 `reason=WIDTH_UNKNOWN` 或 `MEASURE_FAILED`),lineWidth 采用近似值
|
||||
- `widthMode='APPROX'`:
|
||||
- 不调用测量;lineWidth 使用近似值:
|
||||
- EN:`tokenCount`
|
||||
- TC:`charCount`
|
||||
- meta 标记 `reason=WIDTH_UNKNOWN`
|
||||
|
||||
### 3.4 硬约束(最小集)
|
||||
|
||||
与 App 一致的最小硬约束:
|
||||
|
||||
- 禁止空行:`nextPos > pos`
|
||||
- 最大行数:lineIndex 不超过 `maxLines`
|
||||
- TC 禁止行首标点(H6):若 `tokens[nextPos]` 属于 `tcPunctuations` 则该转移无效
|
||||
- 超宽约束:
|
||||
- MEASURE 模式下若 width 可用且 `lineWidth > availableWidth`,转移无效
|
||||
- APPROX 模式下不执行超宽硬过滤(因为单位不一致),交给评分处理
|
||||
|
||||
### 3.5 每轮保留 beamK
|
||||
|
||||
对所有扩展出来的新 beams:
|
||||
|
||||
- 使用 TopK 维护(去重同 breaks,排序按 score/tieKey/breaks)
|
||||
- 全局只保留 `beamK` 个作为下一轮 beams
|
||||
|
||||
### 3.6 结束选择
|
||||
|
||||
重复扩展最多 `maxLines` 轮后:
|
||||
|
||||
- 优先从 beams 中选 `pos==N` 的最优
|
||||
- 若无 `pos==N`:返回 `NO_CANDIDATE`(由 overflow-fallback 统一兜底语义)
|
||||
|
||||
## 4. 代码落位(客户端)
|
||||
|
||||
- `client/src/features/textWrap/searchWidget/`
|
||||
- `types.ts`
|
||||
- `beam.ts`
|
||||
- `topK.ts`(可复用 App 逻辑,首版复制一份避免耦合)
|
||||
- `constraints.ts`
|
||||
- `index.ts`
|
||||
- `__tests__/searchWidget.test.ts`
|
||||
|
||||
## 5. 测试计划(Vitest)
|
||||
|
||||
- 确定性:同输入重复执行输出一致
|
||||
- 性能约束:beamK/expandM 生效(可通过构造大量断点断言扩展次数上限)
|
||||
- widthMode=APPROX:仍能产出结果,且 meta.reason=WIDTH_UNKNOWN
|
||||
- TC H6:行首标点转移被禁止
|
||||
|
||||
## 6. 完成定义(DoD)
|
||||
|
||||
- Beam Search 可运行,且确定性单测通过
|
||||
- 宽度模式与降级 meta 符合 spec
|
||||
- 性能参数默认值写死且可覆盖
|
||||
- 更新 overview 标记阶段性完成并记录变更文件
|
||||
|
||||
46
spec_kit/Text Wrap/modules/search-engine-widget/spec.md
Normal file
46
spec_kit/Text Wrap/modules/search-engine-widget/spec.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# search-engine-widget(子模块规范)
|
||||
|
||||
## 子模块名称
|
||||
|
||||
search-engine-widget(WIDGET 搜索器:Beam Search)
|
||||
|
||||
## 目标描述
|
||||
|
||||
在 WIDGET 场景实现确定性 Beam Search:
|
||||
|
||||
- 每一行扩展时只保留 TopK partial layouts(beam)
|
||||
- 每步扩展断点数 M 受控(更强裁剪)
|
||||
- 支持 width unknown / approx mode
|
||||
- 最终选择 pos==N 的最优解;无解走 `overflow-fallback`
|
||||
|
||||
## 输入/输出定义
|
||||
|
||||
### 输入
|
||||
|
||||
- `tokens: Token[]`
|
||||
- `breakpoints: Breakpoint[]`
|
||||
- `availableWidth: number`(可为常量 profile)
|
||||
- `maxLines: number`
|
||||
- `context: 'WIDGET'`
|
||||
- `measure: { widthMode: 'MEASURE' | 'APPROX' }`
|
||||
- `config: { beamK: number; expandM: number }`
|
||||
|
||||
### 输出
|
||||
|
||||
- `bestLayout: { breaks: number[]; lines: string[]; wrappedText: string; meta?: { breaks: number[]; scoreTopTerms?: ... } }`
|
||||
|
||||
## 验收标准(可验证)
|
||||
|
||||
- **确定性**:beam 扩展、剪枝、排序规则固定;同输入同输出
|
||||
- **性能约束生效**:
|
||||
- beamK 默认 5
|
||||
- 每步扩展 M 默认不超过 12
|
||||
- **宽度不可用降级一致**:
|
||||
- 进入 approx mode 时 meta 标记 `reason=WIDTH_UNKNOWN`
|
||||
- 搜索仍可运行并输出可解释结果
|
||||
|
||||
## 依赖与关联
|
||||
|
||||
- **依赖**:`core-contract`、`width-measurement`、`breakpoint-candidates`、`scoring-tiebreak`、`overflow-fallback`
|
||||
- **被依赖**:`integration`、`golden-tests`
|
||||
|
||||
78
spec_kit/Text Wrap/modules/search-engine-widget/tasks.md
Normal file
78
spec_kit/Text Wrap/modules/search-engine-widget/tasks.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# search-engine-widget(任务清单)
|
||||
|
||||
> 目标:在 WIDGET 场景实现确定性 Beam Search(文档 9.2),支持 `widthMode=MEASURE/APPROX`,并输出最优 layout 或可解释失败原因。
|
||||
|
||||
## 0. 对齐与准备
|
||||
|
||||
- [x] 阅读并对齐口径
|
||||
- [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-widget/spec.md`
|
||||
- [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-widget/plan.md`
|
||||
- [x] 阅读 `设计说明文档/文档换行算法.md` 的 9.2/9.3/11 章节
|
||||
- [x] 创建客户端模块目录
|
||||
- [x] 新建 `client/src/features/textWrap/searchWidget/`
|
||||
|
||||
## 1. 类型定义与导出
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/searchWidget/types.ts`
|
||||
- [x] 定义 `WidthMode = 'MEASURE' | 'APPROX'`
|
||||
- [x] 定义 `SearchWidgetConfig`:`beamK`、`expandM`、`tooLongThresholds`
|
||||
- [x] 定义 `SearchWidgetInput`:
|
||||
- [x] `tokens/lang/breakpoints/availableWidth/maxLines/context='WIDGET'`
|
||||
- [x] `measure: { widthMode; contextProfile?; fontSpec?; measureWidthImpl? }`
|
||||
- [x] `scoring: { config; lexicons; debug? }`
|
||||
- [x] 定义 `SearchWidgetResult`:
|
||||
- [x] 成功:`bestLayout { breaks; lines; wrappedText; meta }`
|
||||
- [x] 失败:`{ ok:false; reason:'TOO_LONG'|'NO_CANDIDATE'; meta? }`
|
||||
- [x] 新建 `client/src/features/textWrap/searchWidget/index.ts`
|
||||
- [x] 统一导出 types 与入口 `searchBestLayoutWidget()`
|
||||
|
||||
## 2. TopK/BeamK 维护(确定性)
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/searchWidget/topK.ts`
|
||||
- [x] 实现 compare:score desc;tieKey asc;breaks 字典序
|
||||
- [x] 实现去重:同 breaks 只保留最优
|
||||
- [x] 实现 `insertTopK`(用于全局 beamK 保留)
|
||||
|
||||
## 3. 约束与降级 meta
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/searchWidget/constraints.ts`
|
||||
- [x] `isTooLong`(TC>60 / EN>30)
|
||||
- [x] `isLineStartPunctTC(tokens, nextPos, tcPunctuations)`(H6)
|
||||
- [x] `approxWidth(line, lang)`(EN=tokenCount / TC=charCount)
|
||||
|
||||
## 4. Beam Search 主流程
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/searchWidget/beam.ts`
|
||||
- [x] 初始化 beams:`[{pos:0, breaks:[], lines:[], score:0}]`
|
||||
- [x] 循环 lineIndex=1..maxLines
|
||||
- [x] 对每个 beam 枚举 nextPos(breakpoints>pos + N)
|
||||
- [x] 裁剪:仅取前 `expandM` 个 nextPos(pos 升序)
|
||||
- [x] 构造行文本:`joinTokens`
|
||||
- [x] 计算行宽:
|
||||
- [x] widthMode=MEASURE:尝试 `measureSliceWidthCached`;失败则降级 approx 并 meta.reason 标记
|
||||
- [x] widthMode=APPROX:直接 approx,并 meta.reason=WIDTH_UNKNOWN
|
||||
- [x] 硬约束过滤:
|
||||
- [x] 禁止空行、maxLines、TC H6
|
||||
- [x] 若 width 可用且 > availableWidth:过滤(仅 MEASURE 可信)
|
||||
- [x] 评分:`scoreLayout` + `buildTieKey`
|
||||
- [x] 全局 newBeams 维护 TopK(beamK)
|
||||
- [x] 结束:从 beams 中选 pos==N 最优,否则 NO_CANDIDATE
|
||||
|
||||
## 5. 单测(Vitest)
|
||||
|
||||
- [x] 新建 `client/src/features/textWrap/searchWidget/__tests__/searchWidget.test.ts`
|
||||
- [x] 确定性:同输入重复执行结果一致
|
||||
- [x] widthMode=APPROX:仍可输出,且 meta.reason=WIDTH_UNKNOWN
|
||||
- [x] 性能约束:expandM/beamK 生效(可统计测量/扩展次数)
|
||||
- [x] TC H6:行首标点被禁止
|
||||
|
||||
## 6. 收尾
|
||||
|
||||
- [x] 跑测试与类型检查
|
||||
- [x] `npm test`
|
||||
- [x] `npx tsc --noEmit`
|
||||
- [x] 将本 `tasks.md` 全部勾选完成
|
||||
- [x] 更新 `spec_kit/overview.md`
|
||||
- [x] 标记 `search-engine-widget` 已完成编码(阶段性)
|
||||
- [x] 写入变更文件清单
|
||||
|
||||
138
spec_kit/Text Wrap/modules/width-measurement/plan.md
Normal file
138
spec_kit/Text Wrap/modules/width-measurement/plan.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# width-measurement(技术计划)
|
||||
|
||||
## 1. 计划目标
|
||||
|
||||
基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,落地 Text Wrap 的“宽度测量与降级(approx mode)”能力,保证:
|
||||
|
||||
- APP 场景可进行**可靠的文本宽度测量**(成熟方案,结果可缓存)
|
||||
- WIDGET 场景允许不测量/测量失败时**确定性降级**(EN=wordCount,TC=charCount/graphemeCount)
|
||||
- 缓存 key、错误处理与降级路径固定,保证**同输入同输出**(确定性)
|
||||
- `fontSpec` 缺失字段直接报错(强制调用方补齐,避免隐式默认导致跨端漂移)
|
||||
- `availableWidth` 由“本机设备侧/上层”传入,本模块不内置 widgetProfiles 常量
|
||||
|
||||
## 2. 默认技术决策(本计划采用)
|
||||
|
||||
### 2.1 测量方案(成熟方法)
|
||||
|
||||
- **APP(RN/Expo)**:采用成熟的“离屏文本测量”库实现 `measureWidth(text, fontSpec)`
|
||||
- 建议选型:`react-native-text-size`(或团队已有的等价成熟方案)
|
||||
- 理由:可直接测量给定字体参数下的文本宽度,避免用 UI 渲染 onLayout 造成异步/不确定性
|
||||
|
||||
> 说明:本计划把“测量实现”作为本子模块交付的一部分,而不是仅定义接口;但仍保留注入点,便于替换实现或做平台差异适配。
|
||||
|
||||
### 2.2 缓存策略(由我统一定义)
|
||||
|
||||
采用“两级缓存 + 有上限”的策略:
|
||||
|
||||
1. **字符串测量缓存**:`(text, fontSpecKey, contextProfile) -> width`
|
||||
2. **切片测量缓存**:`(start, end, fontSpecKey, contextProfile) -> width`
|
||||
|
||||
并且:
|
||||
|
||||
- 缓存采用**模块级常驻缓存**(跨 `wrapText()` 多次调用复用),提升性能
|
||||
- 缓存容量必须有上限(建议 LRU 或“超限清空 + 打点”),避免内存无限增长
|
||||
|
||||
### 2.3 fontSpec 严格性(缺失就报错)
|
||||
|
||||
`fontSpec` 在 APP 测量路径下必须包含:
|
||||
|
||||
- `fontSize`
|
||||
- `fontFamily`
|
||||
- `fontWeight`
|
||||
|
||||
缺失任意字段时:
|
||||
|
||||
- **直接抛错**(错误信息必须为简体中文,并指出缺失字段与调用方应补齐的位置)
|
||||
- 不允许在测量层做“隐式默认值”(避免跨端不一致与线上难定位)
|
||||
|
||||
### 2.4 contextProfile 的口径
|
||||
|
||||
为保证缓存与确定性,本模块定义 `contextProfile`:
|
||||
|
||||
- APP:`APP|<platform>|<scale?>`(按需扩展,但必须固定字段顺序与拼接方式)
|
||||
- WIDGET:`WIDGET|<widgetSize?>`(如果上层传入 widgetSize,可写入;否则仅 WIDGET)
|
||||
|
||||
> 注意:你已确认 `availableWidth` 由设备侧传入,本模块不内置 widgetProfiles;但缓存仍需要一个 profile 字段区分 APP/WIDGET。
|
||||
|
||||
## 3. API 设计(实现阶段稳定契约)
|
||||
|
||||
### 3.1 对外接口(建议)
|
||||
|
||||
- `buildFontSpecKey(fontSpec) -> string`
|
||||
- `measureWidthCached({ text, context, fontSpec, contextProfile }) -> { width: number | null; meta: { isApprox: boolean; reason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' } }`
|
||||
- `measureSliceWidthCached({ tokens, start, end, joinTokens, ... }) -> { width: number | null; meta: ... }`
|
||||
|
||||
### 3.2 approx mode 口径(必须确定性)
|
||||
|
||||
当出现任一情况时进入 approx mode(返回 `width=null`,并由上层按 approx 口径计算 lineLen):
|
||||
|
||||
- `measureWidth` 不可用
|
||||
- `measureWidth` 抛错
|
||||
- `measureWidth` 返回 NaN/Infinity/负数
|
||||
- context=WIDGET 且上层选择不测量
|
||||
|
||||
approx 的“长度单位”口径固定为:
|
||||
|
||||
- EN:`wordCount`
|
||||
- TC:`graphemeCount`(优先;若上层仅有 charCount,则使用 charCount,但必须在实现中写死选择)
|
||||
|
||||
并必须打点 reason:
|
||||
|
||||
- `WIDTH_UNKNOWN`:没有测量能力/不启用测量
|
||||
- `MEASURE_FAILED`:测量抛错或返回非法值
|
||||
|
||||
## 4. 实现步骤(按落地顺序)
|
||||
|
||||
### 4.1 定义类型与错误
|
||||
|
||||
- 定义 `FontSpec`、`ContextProfile`、`MeasureResult` 类型
|
||||
- 定义 `MissingFontSpecError`(或统一错误码),错误信息简体中文
|
||||
|
||||
### 4.2 实现 fontSpecKey
|
||||
|
||||
实现 `fontSpecKey = fontFamily|fontWeight|fontSize`:
|
||||
|
||||
- 字段顺序固定
|
||||
- `fontSize` 转为字符串(禁止浮点格式漂移:建议 `String(fontSize)`,并要求输入是 number 且有限)
|
||||
|
||||
### 4.3 实现缓存容器
|
||||
|
||||
- 实现一个带上限的缓存(LRU 优先;若不引入依赖,先用 Map + 超限清空)
|
||||
- Key 生成必须确定性:
|
||||
- `textKey = <contextProfile>|<fontSpecKey>|<text>`
|
||||
- `sliceKey = <contextProfile>|<fontSpecKey>|<start>|<end>`
|
||||
|
||||
### 4.4 接入成熟测量库(APP)
|
||||
|
||||
- 封装 `measureWidthImpl(text, fontSpec) -> number`
|
||||
- 对返回值做校验(有限、非负)
|
||||
- 失败捕获并走 approx mode(并打 `MEASURE_FAILED`)
|
||||
|
||||
### 4.5 WIDGET 策略
|
||||
|
||||
- 默认允许调用方传入 `measureWidthImpl`(如果 Widget 侧实现了测量)
|
||||
- 若不传/不启用:直接 approx mode,并 reason=`WIDTH_UNKNOWN`
|
||||
|
||||
## 5. 测试计划(Vitest)
|
||||
|
||||
### 5.1 纯函数与缓存测试(必须)
|
||||
|
||||
- `fontSpecKey`:同输入同输出;缺字段抛错
|
||||
- 缓存命中:同 key 不重复调用底层 `measureWidthImpl`
|
||||
- 缓存隔离:不同 `contextProfile/fontSpecKey` 不互相污染
|
||||
|
||||
### 5.2 降级路径测试(必须)
|
||||
|
||||
- 缺少 `measureWidthImpl` -> approx mode + reason=`WIDTH_UNKNOWN`
|
||||
- `measureWidthImpl` 抛错/返回 NaN -> approx mode + reason=`MEASURE_FAILED`
|
||||
|
||||
> 说明:测量库本身的准确性不在单测中做像素级断言;单测只验证“缓存与降级语义确定性”。
|
||||
|
||||
## 6. 完成定义(DoD)
|
||||
|
||||
- APP/WIDGET 下 `width` 返回语义清晰:可测量返回 number,不可测量返回 null
|
||||
- `fontSpec` 缺字段必定报错(简体中文错误信息)
|
||||
- 缓存 key 与容量策略确定性,且有上限
|
||||
- approx mode 触发条件、reason 标记、EN/TC 近似口径写死
|
||||
- 单测覆盖缓存命中/隔离与降级路径
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user