更新换行算法和APP-PUSH

This commit is contained in:
吕新雨
2026-02-10 11:39:33 +08:00
parent f03d36b5e9
commit ee2d9f44ea
105 changed files with 9967 additions and 233 deletions

View File

@@ -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: {

View File

@@ -9,7 +9,7 @@
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/images/splashScreen.png",
"image": "./assets/images/Screen_page.png",
"resizeMode": "contain",
"backgroundColor": "#EAD2BA"
},

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -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

View File

@@ -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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
},

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { wrapText } from '../index';
describe('textWrap integration wrapText', () => {
it('SYSTEM_DEFAULTmeta 标记 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');
});
});

View File

@@ -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([]);
});
});

View 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 固定为 SPACEpriority 固定为 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;
}

View 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 只保留一个 breakpointpriority 更高者优先;同 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 };
}

View File

@@ -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) };
}

View File

@@ -0,0 +1,11 @@
export type {
Breakpoint,
BreakpointConfig,
BreakpointConstraints,
BreakpointKind,
BreakpointMeta,
GenerateBreakpointsInput,
} from './types';
export { generateBreakpoints } from './generateBreakpoints';

View 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=PUNCTpriority=30
* - 空格后kind=SPACEpriority=20
* - BALANCEkind=BALANCEpriority=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 };
}

View 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;
};

View File

@@ -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);
});
});

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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 };
}

View 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;
}

View 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 的原始文本ENTC字符簇 */
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.12.3.1A-1
*/
punctuationStripSetEN: string[];
};

View 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);
});
});

View 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 也是“字符数单位”
* - WIDGETwidthMode=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好累' },
},
];

View File

@@ -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);
}
});
});

View File

@@ -0,0 +1,9 @@
export type {
GraphemeSegmentationMeta,
GraphemeSegmentationMode,
GraphemeSegmentationResult,
GraphemeSegmentationStrategy,
} from './types';
export { segmentGraphemes } from './segmentGraphemes';

View 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 } };
}

View 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;
}

View File

@@ -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 仅用于选择合适的 localegrapheme 分割应与语言本身关系不大,但保持固定输入更易对齐跨端。
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;
}

View 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;
};

View File

@@ -0,0 +1,4 @@
export type { WrapTextConstraints, WrapTextInput, WrapTextMeta, WrapTextOutput } from './types';
export { wrapText } from './wrapText';

View File

@@ -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);
});
});

View File

@@ -0,0 +1,62 @@
/**
* Text Wrap - width-measurement 缓存
*
* 要求:
* - 模块级常驻缓存(跨调用复用)
* - 有容量上限(避免内存无限增长)
* - Key 必须确定性(由上层拼接传入)
*
* 说明:
* - 这里实现一个最小 LRUMap 维护插入顺序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;
}
}

View 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() 时补齐 fontSpecfontFamily/fontWeight/fontSize禁止在测量层使用隐式默认值。`;
}

View 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)}`;
}

View 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';

View 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' } };
}
}

View 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;
}
/**
* 测量文本宽度(带缓存)。
*
* 约束:
* - 若启用测量(提供 measureWidthImplfontSpec 必须完整;缺字段直接报错(简体中文)
* - 若不启用测量measureWidthImpl 缺失或 context=WIDGET 且明确不启用),进入 approx modewidth=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' } };
}
}

View 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 Buildexpo-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;
};

View 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>;

View File

@@ -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');
});
});

View 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 回退单位graphemetoken
*/
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 };
}

View 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 的单行文本在上层保证传入“原文”更稳妥。
// 为保持最小可用,这里仍使用默认 joinTokensEN=空格 joinTC=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 } };
}

View File

@@ -0,0 +1,15 @@
export type {
ApplyOverflowFallbackInput,
ApplyOverflowFallbackResult,
FallbackType,
OverflowMeasure,
OverflowMode,
OverflowReason,
OverflowType,
PartialLayoutInput,
PartialLayoutLine,
WrapTextMeta,
} from './types';
export { applyOverflowFallback } from './fallback';

View 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;
};

View File

@@ -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/SelfEN 词两端带标点也应命中(全词等值匹配)', () => {
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.2GEmotionWord 与 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 -> 20floor
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('tieKeylastLineWidth 更大优先(取 -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);
});
});

View 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';

View 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 };
}

View 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);
}

View 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 转为 900bp=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.4Adebug 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 widowEN/ 单字行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 };
}

View 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,
];
}

View 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 短词阈值ENlen(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;
};

View 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 };
}

View File

@@ -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');
});
});

View 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;
}

View 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;
// 枚举 nextPosbreakpoints 中 >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; // 禁止空行
// H6TC 禁止“行首标点”(断点导致下一行第一个 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)) };
}

View File

@@ -0,0 +1,4 @@
export type { BestLayout, LineMode, SearchAppConfig, SearchAppInput, SearchAppResult, SearchFailureReason } from './types';
export { searchBestLayoutApp } from './dpTopK';

View 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;
}

View 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[];
};

View File

@@ -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');
});
});

View 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;
// nextPospos 升序 + N
const nextList: number[] = [];
for (const p of positions) if (p > pos) nextList.push(p);
if (n > pos) nextList.push(n);
// expandM 裁剪:只取前 M 个(确定性:按 pos 升序),但必须保证 N结束边界始终可选
// 否则会出现“明明一行就能结束,却被裁剪掉只能继续拆分”的怪异换行。
const M = Math.max(1, cfg.expandM | 0);
let trimmed: number[];
if (M === 1) {
trimmed = [n];
} else {
const withoutN = nextList.filter((x) => x !== n);
trimmed = withoutN.slice(0, M - 1);
trimmed.push(n);
}
for (const nextPos of trimmed) {
if (nextPos <= pos) continue; // 禁止空行
// H6TC 禁止行首标点(断点导致下一行第一个 token 为标点)
if (lang === 'TC' && nextPos < n && isLineStartPunctTC(tokens, nextPos, tcPunctuations)) {
continue;
}
const tokenCount = nextPos - pos;
const charCount = lang === 'TC' ? tokenCount : 0;
const lineText = buildLineText(tokens, pos, nextPos, rawSep);
let lineWidth: number;
let isApprox = false;
let approxReason: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | undefined;
let widthTrusted = false;
if (input.measure.widthMode === 'APPROX') {
isApprox = true;
approxReason = 'WIDTH_UNKNOWN';
lineWidth = approxWidth(tokenCount, charCount, lang);
} else {
const widgetEnableMeasure = input.measure.widgetEnableMeasure ?? true;
const res = await measureSliceWidthCached({
tokens,
start: pos,
end: nextPos,
context: 'WIDGET',
contextProfile: input.measure.contextProfile,
fontSpec: input.measure.fontSpec,
measureWidthImpl: input.measure.measureWidthImpl,
widgetEnableMeasure,
rawSeparators: rawSep,
});
if (res.width === null) {
isApprox = true;
approxReason = res.meta.reason ?? 'WIDTH_UNKNOWN';
lineWidth = approxWidth(tokenCount, charCount, lang);
} else {
widthTrusted = true;
lineWidth = res.width;
}
}
// 超宽过滤:仅当宽度可信时执行
if (widthTrusted && lineWidth > availableWidth) continue;
const newLine = {
start: pos,
end: nextPos,
text: lineText,
width: lineWidth,
tokenCount,
charCount,
isApprox,
approxReason,
};
const newLines = beam.lines.concat([newLine]);
const newBreaks = nextPos === n ? beam.breaks : beam.breaks.concat([nextPos]);
const layoutCandidate = { breaks: newBreaks, lines: newLines.map(({ isApprox: _a, approxReason: _b, ...rest }) => rest) };
const scored = scoreLayout({
tokens,
layoutCandidate: layoutCandidate as any,
lang,
context: 'WIDGET',
availableWidth,
config: input.scoring.config,
lexicons: input.scoring.lexicons,
debug: Boolean(input.scoring.debug),
});
const tieKey = buildTieKey({
scoredLayout: scored as any,
layoutCandidate: layoutCandidate as any,
lang,
context: 'WIDGET',
tokenCount: n,
availableWidth,
idealWidthRatio: input.scoring.config.idealWidthRatio,
}) as number[];
const cand: WidgetBeam = {
pos: nextPos,
breaks: newBreaks,
lines: newLines,
score: scored.score,
tieKey,
scoreTopTerms: scored.scoreBreakdown?.terms,
};
newBeams = insertBeamTopK(newBeams, cand, cfg.beamK);
}
}
beams = newBeams;
if (beams.length === 0) break;
}
const finished = beams.filter((b) => b.pos === n);
if (finished.length === 0) {
return { ok: false, reason: 'NO_CANDIDATE', meta: { tokenCount: n } };
}
finished.sort((a, b) => {
if (a.score !== b.score) return b.score - a.score;
const na = a.tieKey;
const nb = b.tieKey;
const m = Math.min(na.length, nb.length);
for (let i = 0; i < m; i++) {
const av = na[i] ?? 0;
const bv = nb[i] ?? 0;
if (av < bv) return -1;
if (av > bv) return 1;
}
return na.length - nb.length;
});
const best = finished[0]!;
return { ok: true, bestLayout: buildBestLayout(best, Boolean(input.scoring.debug)) };
}

View 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;
}

View File

@@ -0,0 +1,13 @@
export type {
BestWidgetLayout,
SearchWidgetConfig,
SearchWidgetFailureReason,
SearchWidgetInput,
SearchWidgetMeasureInput,
SearchWidgetResult,
WidthMode,
WidgetBeam,
} from './types';
export { searchBestLayoutWidget } from './beam';

View 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;
}

View 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[];
};

View 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;
};

View 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 },
});
// scoringprotectedPhrases 从 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 },
};
}