From ee2d9f44ea7c5f4d5fed19616365d2bd100a5803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Tue, 10 Feb 2026 11:39:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=8D=A2=E8=A1=8C=E7=AE=97?= =?UTF-8?q?=E6=B3=95=E5=92=8CAPP-PUSH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/commands/myspec.split-spec.md | 2 +- client/app.config.ts | 7 +- client/app.json | 2 +- client/app/(app)/home.tsx | 141 +- client/assets/images/Screen_page.png | Bin 0 -> 156043 bytes client/ios/Podfile.lock | 392 ++--- client/ios/client.xcodeproj/project.pbxproj | 33 +- client/ios/client/SplashScreen.storyboard | 4 +- client/package-lock.json | 1540 ++++++++++++++++- client/package.json | 2 + .../__tests__/wrapText.integration.test.ts | 24 + .../__tests__/generateBreakpoints.test.ts | 98 ++ .../textWrap/breakpoints/enCandidates.ts | 22 + .../textWrap/breakpoints/filterAndDedup.ts | 89 + .../breakpoints/generateBreakpoints.ts | 59 + .../features/textWrap/breakpoints/index.ts | 11 + .../textWrap/breakpoints/tcCandidates.ts | 78 + .../features/textWrap/breakpoints/types.ts | 37 + .../core/__tests__/core-contract.test.ts | 48 + client/src/features/textWrap/core/compare.ts | 25 + .../src/features/textWrap/core/enKeyword.ts | 68 + client/src/features/textWrap/core/index.ts | 8 + .../src/features/textWrap/core/joinTokens.ts | 37 + .../textWrap/core/normalizeWhitespace.ts | 28 + .../src/features/textWrap/core/tokenizeEN.ts | 38 + client/src/features/textWrap/core/types.ts | 40 + .../textWrap/golden/__tests__/golden.test.ts | 97 ++ .../src/features/textWrap/golden/fixtures.ts | 56 + .../__tests__/segmentGraphemes.test.ts | 65 + .../src/features/textWrap/grapheme/index.ts | 9 + .../textWrap/grapheme/segmentGraphemes.ts | 42 + .../textWrap/grapheme/strategies/fallback.ts | 81 + .../grapheme/strategies/intlSegmenter.ts | 28 + .../src/features/textWrap/grapheme/types.ts | 21 + client/src/features/textWrap/index.ts | 4 + .../__tests__/widthMeasurement.test.ts | 101 ++ client/src/features/textWrap/measure/cache.ts | 62 + .../src/features/textWrap/measure/errors.ts | 19 + .../features/textWrap/measure/fontSpecKey.ts | 32 + client/src/features/textWrap/measure/index.ts | 16 + .../measure/measureSliceWidthCached.ts | 105 ++ .../textWrap/measure/measureWidthCached.ts | 110 ++ .../textWrap/measure/measureWidthImpl.ts | 65 + client/src/features/textWrap/measure/types.ts | 48 + .../__tests__/overflowFallback.test.ts | 107 ++ .../features/textWrap/overflow/ellipsis.ts | 108 ++ .../features/textWrap/overflow/fallback.ts | 86 + .../src/features/textWrap/overflow/index.ts | 15 + .../src/features/textWrap/overflow/types.ts | 47 + .../scoring/__tests__/scoringTiebreak.test.ts | 151 ++ client/src/features/textWrap/scoring/index.ts | 21 + .../src/features/textWrap/scoring/lexicons.ts | 31 + .../features/textWrap/scoring/phraseMatch.ts | 92 + client/src/features/textWrap/scoring/score.ts | 367 ++++ .../src/features/textWrap/scoring/tieKey.ts | 96 + client/src/features/textWrap/scoring/types.ts | 92 + .../src/features/textWrap/scoring/weights.ts | 35 + .../searchApp/__tests__/searchApp.test.ts | 124 ++ .../textWrap/searchApp/constraints.ts | 21 + .../src/features/textWrap/searchApp/dpTopK.ts | 221 +++ .../src/features/textWrap/searchApp/index.ts | 4 + .../src/features/textWrap/searchApp/topK.ts | 62 + .../src/features/textWrap/searchApp/types.ts | 71 + .../__tests__/searchWidget.test.ts | 138 ++ .../features/textWrap/searchWidget/beam.ts | 263 +++ .../textWrap/searchWidget/constraints.ts | 21 + .../features/textWrap/searchWidget/index.ts | 13 + .../features/textWrap/searchWidget/topK.ts | 52 + .../features/textWrap/searchWidget/types.ts | 91 + client/src/features/textWrap/types.ts | 55 + client/src/features/textWrap/wrapText.ts | 173 ++ server/celerybeat-schedule | Bin 12288 -> 12288 bytes .../modules/breakpoint-candidates/plan.md | 162 ++ .../modules/breakpoint-candidates/spec.md | 51 + .../modules/breakpoint-candidates/tasks.md | 164 ++ .../Text Wrap/modules/core-contract/plan.md | 111 ++ .../Text Wrap/modules/core-contract/spec.md | 59 + .../Text Wrap/modules/core-contract/tasks.md | 150 ++ .../Text Wrap/modules/golden-tests/plan.md | 57 + .../Text Wrap/modules/golden-tests/spec.md | 57 + .../Text Wrap/modules/golden-tests/tasks.md | 50 + .../modules/grapheme-segmentation/plan.md | 136 ++ .../modules/grapheme-segmentation/spec.md | 52 + .../modules/grapheme-segmentation/tasks.md | 126 ++ .../Text Wrap/modules/integration/plan.md | 77 + .../Text Wrap/modules/integration/spec.md | 52 + .../Text Wrap/modules/integration/tasks.md | 63 + .../modules/overflow-fallback/plan.md | 109 ++ .../modules/overflow-fallback/spec.md | 52 + .../modules/overflow-fallback/tasks.md | 65 + .../modules/scoring-tiebreak/plan.md | 179 ++ .../modules/scoring-tiebreak/spec.md | 54 + .../modules/scoring-tiebreak/tasks.md | 122 ++ .../modules/search-engine-app/plan.md | 104 ++ .../modules/search-engine-app/spec.md | 51 + .../modules/search-engine-app/tasks.md | 92 + .../modules/search-engine-widget/plan.md | 115 ++ .../modules/search-engine-widget/spec.md | 46 + .../modules/search-engine-widget/tasks.md | 78 + .../modules/width-measurement/plan.md | 138 ++ .../modules/width-measurement/spec.md | 51 + .../modules/width-measurement/tasks.md | 160 ++ spec_kit/Text Wrap/spec.md | 109 ++ spec_kit/overview.md | 80 + 设计说明文档/文档换行算法.md | 877 ++++++++++ 105 files changed, 9967 insertions(+), 233 deletions(-) create mode 100644 client/assets/images/Screen_page.png create mode 100644 client/src/features/textWrap/__tests__/wrapText.integration.test.ts create mode 100644 client/src/features/textWrap/breakpoints/__tests__/generateBreakpoints.test.ts create mode 100644 client/src/features/textWrap/breakpoints/enCandidates.ts create mode 100644 client/src/features/textWrap/breakpoints/filterAndDedup.ts create mode 100644 client/src/features/textWrap/breakpoints/generateBreakpoints.ts create mode 100644 client/src/features/textWrap/breakpoints/index.ts create mode 100644 client/src/features/textWrap/breakpoints/tcCandidates.ts create mode 100644 client/src/features/textWrap/breakpoints/types.ts create mode 100644 client/src/features/textWrap/core/__tests__/core-contract.test.ts create mode 100644 client/src/features/textWrap/core/compare.ts create mode 100644 client/src/features/textWrap/core/enKeyword.ts create mode 100644 client/src/features/textWrap/core/index.ts create mode 100644 client/src/features/textWrap/core/joinTokens.ts create mode 100644 client/src/features/textWrap/core/normalizeWhitespace.ts create mode 100644 client/src/features/textWrap/core/tokenizeEN.ts create mode 100644 client/src/features/textWrap/core/types.ts create mode 100644 client/src/features/textWrap/golden/__tests__/golden.test.ts create mode 100644 client/src/features/textWrap/golden/fixtures.ts create mode 100644 client/src/features/textWrap/grapheme/__tests__/segmentGraphemes.test.ts create mode 100644 client/src/features/textWrap/grapheme/index.ts create mode 100644 client/src/features/textWrap/grapheme/segmentGraphemes.ts create mode 100644 client/src/features/textWrap/grapheme/strategies/fallback.ts create mode 100644 client/src/features/textWrap/grapheme/strategies/intlSegmenter.ts create mode 100644 client/src/features/textWrap/grapheme/types.ts create mode 100644 client/src/features/textWrap/index.ts create mode 100644 client/src/features/textWrap/measure/__tests__/widthMeasurement.test.ts create mode 100644 client/src/features/textWrap/measure/cache.ts create mode 100644 client/src/features/textWrap/measure/errors.ts create mode 100644 client/src/features/textWrap/measure/fontSpecKey.ts create mode 100644 client/src/features/textWrap/measure/index.ts create mode 100644 client/src/features/textWrap/measure/measureSliceWidthCached.ts create mode 100644 client/src/features/textWrap/measure/measureWidthCached.ts create mode 100644 client/src/features/textWrap/measure/measureWidthImpl.ts create mode 100644 client/src/features/textWrap/measure/types.ts create mode 100644 client/src/features/textWrap/overflow/__tests__/overflowFallback.test.ts create mode 100644 client/src/features/textWrap/overflow/ellipsis.ts create mode 100644 client/src/features/textWrap/overflow/fallback.ts create mode 100644 client/src/features/textWrap/overflow/index.ts create mode 100644 client/src/features/textWrap/overflow/types.ts create mode 100644 client/src/features/textWrap/scoring/__tests__/scoringTiebreak.test.ts create mode 100644 client/src/features/textWrap/scoring/index.ts create mode 100644 client/src/features/textWrap/scoring/lexicons.ts create mode 100644 client/src/features/textWrap/scoring/phraseMatch.ts create mode 100644 client/src/features/textWrap/scoring/score.ts create mode 100644 client/src/features/textWrap/scoring/tieKey.ts create mode 100644 client/src/features/textWrap/scoring/types.ts create mode 100644 client/src/features/textWrap/scoring/weights.ts create mode 100644 client/src/features/textWrap/searchApp/__tests__/searchApp.test.ts create mode 100644 client/src/features/textWrap/searchApp/constraints.ts create mode 100644 client/src/features/textWrap/searchApp/dpTopK.ts create mode 100644 client/src/features/textWrap/searchApp/index.ts create mode 100644 client/src/features/textWrap/searchApp/topK.ts create mode 100644 client/src/features/textWrap/searchApp/types.ts create mode 100644 client/src/features/textWrap/searchWidget/__tests__/searchWidget.test.ts create mode 100644 client/src/features/textWrap/searchWidget/beam.ts create mode 100644 client/src/features/textWrap/searchWidget/constraints.ts create mode 100644 client/src/features/textWrap/searchWidget/index.ts create mode 100644 client/src/features/textWrap/searchWidget/topK.ts create mode 100644 client/src/features/textWrap/searchWidget/types.ts create mode 100644 client/src/features/textWrap/types.ts create mode 100644 client/src/features/textWrap/wrapText.ts create mode 100644 spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md create mode 100644 spec_kit/Text Wrap/modules/breakpoint-candidates/spec.md create mode 100644 spec_kit/Text Wrap/modules/breakpoint-candidates/tasks.md create mode 100644 spec_kit/Text Wrap/modules/core-contract/plan.md create mode 100644 spec_kit/Text Wrap/modules/core-contract/spec.md create mode 100644 spec_kit/Text Wrap/modules/core-contract/tasks.md create mode 100644 spec_kit/Text Wrap/modules/golden-tests/plan.md create mode 100644 spec_kit/Text Wrap/modules/golden-tests/spec.md create mode 100644 spec_kit/Text Wrap/modules/golden-tests/tasks.md create mode 100644 spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md create mode 100644 spec_kit/Text Wrap/modules/grapheme-segmentation/spec.md create mode 100644 spec_kit/Text Wrap/modules/grapheme-segmentation/tasks.md create mode 100644 spec_kit/Text Wrap/modules/integration/plan.md create mode 100644 spec_kit/Text Wrap/modules/integration/spec.md create mode 100644 spec_kit/Text Wrap/modules/integration/tasks.md create mode 100644 spec_kit/Text Wrap/modules/overflow-fallback/plan.md create mode 100644 spec_kit/Text Wrap/modules/overflow-fallback/spec.md create mode 100644 spec_kit/Text Wrap/modules/overflow-fallback/tasks.md create mode 100644 spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md create mode 100644 spec_kit/Text Wrap/modules/scoring-tiebreak/spec.md create mode 100644 spec_kit/Text Wrap/modules/scoring-tiebreak/tasks.md create mode 100644 spec_kit/Text Wrap/modules/search-engine-app/plan.md create mode 100644 spec_kit/Text Wrap/modules/search-engine-app/spec.md create mode 100644 spec_kit/Text Wrap/modules/search-engine-app/tasks.md create mode 100644 spec_kit/Text Wrap/modules/search-engine-widget/plan.md create mode 100644 spec_kit/Text Wrap/modules/search-engine-widget/spec.md create mode 100644 spec_kit/Text Wrap/modules/search-engine-widget/tasks.md create mode 100644 spec_kit/Text Wrap/modules/width-measurement/plan.md create mode 100644 spec_kit/Text Wrap/modules/width-measurement/spec.md create mode 100644 spec_kit/Text Wrap/modules/width-measurement/tasks.md create mode 100644 spec_kit/Text Wrap/spec.md create mode 100644 设计说明文档/文档换行算法.md diff --git a/.cursor/commands/myspec.split-spec.md b/.cursor/commands/myspec.split-spec.md index 6487951..727dd56 100644 --- a/.cursor/commands/myspec.split-spec.md +++ b/.cursor/commands/myspec.split-spec.md @@ -1,4 +1,4 @@ -当前有一个很大的 spec.md(大需求规范),需要按业务逻辑拆分成多个子模块规范。 +当前有一个很大的 spec.md(大需求规范),需要按业务逻辑合理拆分成多个子模块规范。 请按以下规则拆分: diff --git a/client/app.config.ts b/client/app.config.ts index 08dff82..a057495 100644 --- a/client/app.config.ts +++ b/client/app.config.ts @@ -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: { diff --git a/client/app.json b/client/app.json index 717643e..f386662 100644 --- a/client/app.json +++ b/client/app.json @@ -9,7 +9,7 @@ "userInterfaceStyle": "automatic", "newArchEnabled": true, "splash": { - "image": "./assets/images/splashScreen.png", + "image": "./assets/images/Screen_page.png", "resizeMode": "contain", "backgroundColor": "#EAD2BA" }, diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index 23f1046..6cfbd27 100644 --- a/client/app/(app)/home.tsx +++ b/client/app/(app)/home.tsx @@ -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([]); const [isFetching, setIsFetching] = useState(false); + const [cardWidth, setCardWidth] = useState(null); + const [wrappedText, setWrappedText] = useState(''); + 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() { - + { + const w = e.nativeEvent.layout.width; + if (Number.isFinite(w) && w > 0) setCardWidth(w); + }} + > - {item.text} + {wrappedText || item.text} diff --git a/client/assets/images/Screen_page.png b/client/assets/images/Screen_page.png new file mode 100644 index 0000000000000000000000000000000000000000..656df30b2cb60400eee08b0a960628fcfa5e8213 GIT binary patch literal 156043 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 literal 0 HcmV?d00001 diff --git a/client/ios/Podfile.lock b/client/ios/Podfile.lock index 4a3e609..cc8c4c5 100644 --- a/client/ios/Podfile.lock +++ b/client/ios/Podfile.lock @@ -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 diff --git a/client/ios/client.xcodeproj/project.pbxproj b/client/ios/client.xcodeproj/project.pbxproj index e0139a9..9893a78 100644 --- a/client/ios/client.xcodeproj/project.pbxproj +++ b/client/ios/client.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = ""; }; - A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = ""; }; A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = ""; }; A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = ""; }; + C0A1B2C3D4E5F60718293A4D /* Screen_page.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Screen_page.png; path = ../assets/images/Screen_page.png; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = ""; }; 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 = ""; }; + EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = "情绪小组件"; + sourceTree = ""; + }; /* 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 = ""; @@ -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; diff --git a/client/ios/client/SplashScreen.storyboard b/client/ios/client/SplashScreen.storyboard index 2323717..c58f06a 100644 --- a/client/ios/client/SplashScreen.storyboard +++ b/client/ios/client/SplashScreen.storyboard @@ -17,7 +17,7 @@ - + @@ -37,7 +37,7 @@ - + diff --git a/client/package-lock.json b/client/package-lock.json index c857d57..c747c50 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -7,13 +7,15 @@ "": { "name": "client", "version": "1.0.0", - "hasInstallScript": true, "dependencies": { "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/native": "^7.1.8", "expo": "~54.0.32", "expo-constants": "~18.0.13", + "expo-crypto": "^15.0.8", + "expo-dev-client": "^6.0.20", + "expo-device": "^8.0.10", "expo-font": "~14.0.11", "expo-linear-gradient": "^15.0.8", "expo-linking": "~8.0.11", @@ -23,24 +25,27 @@ "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", - "pnpm": "^10.28.2", "react": "19.1.0", "react-dom": "19.1.0", "react-i18next": "^16.5.4", "react-native": "0.81.5", + "react-native-gesture-handler": "^2.30.0", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "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" }, "devDependencies": { "@types/react": "~19.1.0", "react-test-renderer": "19.1.0", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vitest": "^4.0.18" } }, "node_modules/@0no-co/graphql.web": { @@ -1534,6 +1539,433 @@ "node": ">=6.9.0" } }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmmirror.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -2784,6 +3216,331 @@ "nanoid": "^3.3.11" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2808,6 +3565,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -3092,6 +3855,28 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3101,6 +3886,11 @@ "@types/node": "*" } }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmmirror.com/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3194,6 +3984,110 @@ "@urql/core": "^5.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -3249,6 +4143,21 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/anser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", @@ -3371,6 +4280,15 @@ "util": "^0.12.5" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -3897,6 +4815,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4670,6 +5597,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4682,6 +5615,47 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4722,6 +5696,15 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4746,6 +5729,15 @@ "integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expo": { "version": "54.0.32", "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.32.tgz", @@ -4836,6 +5828,100 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "15.0.8", + "resolved": "https://registry.npmmirror.com/expo-crypto/-/expo-crypto-15.0.8.tgz", + "integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-client": { + "version": "6.0.20", + "resolved": "https://registry.npmmirror.com/expo-dev-client/-/expo-dev-client-6.0.20.tgz", + "integrity": "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==", + "dependencies": { + "expo-dev-launcher": "6.0.20", + "expo-dev-menu": "7.0.18", + "expo-dev-menu-interface": "2.0.0", + "expo-manifests": "~1.0.10", + "expo-updates-interface": "~2.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-launcher": { + "version": "6.0.20", + "resolved": "https://registry.npmmirror.com/expo-dev-launcher/-/expo-dev-launcher-6.0.20.tgz", + "integrity": "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA==", + "dependencies": { + "ajv": "^8.11.0", + "expo-dev-menu": "7.0.18", + "expo-manifests": "~1.0.10" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-menu": { + "version": "7.0.18", + "resolved": "https://registry.npmmirror.com/expo-dev-menu/-/expo-dev-menu-7.0.18.tgz", + "integrity": "sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA==", + "dependencies": { + "expo-dev-menu-interface": "2.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-menu-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz", + "integrity": "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw==", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device": { + "version": "8.0.10", + "resolved": "https://registry.npmmirror.com/expo-device/-/expo-device-8.0.10.tgz", + "integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device/node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmmirror.com/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.21", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", @@ -4860,6 +5946,11 @@ "react-native": "*" } }, + "node_modules/expo-json-utils": { + "version": "0.15.0", + "resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-0.15.0.tgz", + "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==" + }, "node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", @@ -4908,6 +5999,18 @@ "react": "*" } }, + "node_modules/expo-manifests": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/expo-manifests/-/expo-manifests-1.0.10.tgz", + "integrity": "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==", + "dependencies": { + "@expo/config": "~12.0.11", + "expo-json-utils": "~0.15.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.24", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", @@ -5244,6 +6347,14 @@ "react-native": "*" } }, + "node_modules/expo-updates-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz", + "integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-web-browser": { "version": "15.0.10", "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", @@ -5419,6 +6530,21 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -5752,6 +6878,11 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5827,6 +6958,19 @@ "hermes-estree": "0.29.1" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -6476,6 +7620,11 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6932,6 +8081,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -7618,6 +8776,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ] + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -7960,6 +9128,12 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8010,22 +9184,6 @@ "node": ">=4.0.0" } }, - "node_modules/pnpm": { - "version": "10.28.2", - "resolved": "https://registry.npmjs.org/pnpm/-/pnpm-10.28.2.tgz", - "integrity": "sha512-QYcvA3rSL3NI47Heu69+hnz9RI8nJtnPdMCPGVB8MdLI56EVJbmD/rwt9kC1Q43uYCPrsfhO1DzC1lTSvDJiZA==", - "license": "MIT", - "bin": { - "pnpm": "bin/pnpm.cjs", - "pnpx": "bin/pnpx.cjs" - }, - "engines": { - "node": ">=18.12" - }, - "funding": { - "url": "https://opencollective.com/pnpm" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -8360,6 +9518,20 @@ } } }, + "node_modules/react-native-gesture-handler": { + "version": "2.30.0", + "resolved": "https://registry.npmmirror.com/react-native-gesture-handler/-/react-native-gesture-handler-2.30.0.tgz", + "integrity": "sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA==", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", @@ -8454,6 +9626,14 @@ "react-native-svg": ">=12.0.0" } }, + "node_modules/react-native-text-size": { + "version": "4.0.0-rc.1", + "resolved": "https://registry.npmmirror.com/react-native-text-size/-/react-native-text-size-4.0.0-rc.1.tgz", + "integrity": "sha512-CysqjU2jK6Yc+a+kEI222pUyTY2ywcU2HqbFqf1KHymW6OPTdvBBHqbEJKL0QiLhQaFYDbqicM+h990s9TP00g==", + "peerDependencies": { + "react-native": ">=0.59.0" + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", @@ -8937,6 +10117,50 @@ "node": "*" } }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, "node_modules/rtl-detect": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz", @@ -9189,6 +10413,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -9328,6 +10558,12 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -9355,6 +10591,12 @@ "node": ">= 0.6" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, "node_modules/stream-buffers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", @@ -9696,6 +10938,21 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -9741,6 +10998,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -10250,6 +11516,226 @@ } } }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", @@ -10370,6 +11856,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wonka": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", diff --git a/client/package.json b/client/package.json index 3dc31be..093582d 100644 --- a/client/package.json +++ b/client/package.json @@ -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" }, diff --git a/client/src/features/textWrap/__tests__/wrapText.integration.test.ts b/client/src/features/textWrap/__tests__/wrapText.integration.test.ts new file mode 100644 index 0000000..179756f --- /dev/null +++ b/client/src/features/textWrap/__tests__/wrapText.integration.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { wrapText } from '../index'; + +describe('textWrap integration wrapText', () => { + it('SYSTEM_DEFAULT:meta 标记 SYSTEM_DEFAULT 且仍返回 lines/wrappedText', async () => { + const res = await wrapText({ + text: 'I am so tired', + lang: 'EN', + context: 'APP', + availableWidth: 1, + maxLines: 1, + overflowMode: 'SYSTEM_DEFAULT', + // 不提供测量能力,逼迫走兜底 + fontSpec: null, + measureWidthImpl: undefined, + }); + + expect(res.lines.length).toBeGreaterThan(0); + expect(res.wrappedText.length).toBeGreaterThan(0); + expect(res.meta?.fallback_type).toBe('SYSTEM_DEFAULT'); + }); +}); + diff --git a/client/src/features/textWrap/breakpoints/__tests__/generateBreakpoints.test.ts b/client/src/features/textWrap/breakpoints/__tests__/generateBreakpoints.test.ts new file mode 100644 index 0000000..8efef5d --- /dev/null +++ b/client/src/features/textWrap/breakpoints/__tests__/generateBreakpoints.test.ts @@ -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([]); + }); +}); + diff --git a/client/src/features/textWrap/breakpoints/enCandidates.ts b/client/src/features/textWrap/breakpoints/enCandidates.ts new file mode 100644 index 0000000..65497c6 --- /dev/null +++ b/client/src/features/textWrap/breakpoints/enCandidates.ts @@ -0,0 +1,22 @@ +import type { Breakpoint } from './types'; +import type { Token } from '../core/types'; + +/** + * EN 候选断点生成(极简派) + * + * 口径: + * - tokens 仅为 WORD(不包含 SPACE token) + * - 候选断点只生成在 `pos ∈ [1, N-1]` + * - kind 固定为 SPACE,priority 固定为 10 + */ +export function generateEnCandidates(tokens: Token[]): Breakpoint[] { + const n = tokens.length; + if (n <= 1) return []; + + const out: Breakpoint[] = []; + for (let pos = 1; pos <= n - 1; pos++) { + out.push({ pos, kind: 'SPACE', priority: 10 }); + } + return out; +} + diff --git a/client/src/features/textWrap/breakpoints/filterAndDedup.ts b/client/src/features/textWrap/breakpoints/filterAndDedup.ts new file mode 100644 index 0000000..78a3e26 --- /dev/null +++ b/client/src/features/textWrap/breakpoints/filterAndDedup.ts @@ -0,0 +1,89 @@ +import type { Breakpoint, BreakpointKind, BreakpointMeta, BreakpointConstraints } from './types'; + +const KIND_ORDER: Record = { + PUNCT: 0, + SPACE: 1, + BALANCE: 2, + OTHER: 3, +}; + +function betterBreakpoint(a: Breakpoint, b: Breakpoint): Breakpoint { + if (a.priority !== b.priority) return a.priority > b.priority ? a : b; + const ka = KIND_ORDER[a.kind] ?? 999; + const kb = KIND_ORDER[b.kind] ?? 999; + if (ka !== kb) return ka < kb ? a : b; + // 完全相同优先级时,稳定选择 pos 更小者(但同 pos 才会进入该比较) + return a; +} + +/** + * forbiddenBreakRanges 过滤(闭区间口径) + * - start <= pos && pos <= end 命中则剔除 + */ +export function filterForbiddenBreakRanges( + breakpoints: Breakpoint[], + constraints: BreakpointConstraints | undefined +): Breakpoint[] { + const ranges = constraints?.forbiddenBreakRanges; + if (!ranges || ranges.length === 0) return breakpoints; + + return breakpoints.filter((bp) => { + for (const r of ranges) { + const s = r.start | 0; + const e = r.end | 0; + if (s <= bp.pos && bp.pos <= e) return false; + } + return true; + }); +} + +/** + * 去重:同 pos 只保留一个 breakpoint(priority 更高者优先;同 priority 按 kind 固定序) + */ +export function dedupByPos(breakpoints: Breakpoint[]): Breakpoint[] { + const map = new Map(); + 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 }; +} + diff --git a/client/src/features/textWrap/breakpoints/generateBreakpoints.ts b/client/src/features/textWrap/breakpoints/generateBreakpoints.ts new file mode 100644 index 0000000..4a80fdc --- /dev/null +++ b/client/src/features/textWrap/breakpoints/generateBreakpoints.ts @@ -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) }; +} + diff --git a/client/src/features/textWrap/breakpoints/index.ts b/client/src/features/textWrap/breakpoints/index.ts new file mode 100644 index 0000000..d1a5296 --- /dev/null +++ b/client/src/features/textWrap/breakpoints/index.ts @@ -0,0 +1,11 @@ +export type { + Breakpoint, + BreakpointConfig, + BreakpointConstraints, + BreakpointKind, + BreakpointMeta, + GenerateBreakpointsInput, +} from './types'; + +export { generateBreakpoints } from './generateBreakpoints'; + diff --git a/client/src/features/textWrap/breakpoints/tcCandidates.ts b/client/src/features/textWrap/breakpoints/tcCandidates.ts new file mode 100644 index 0000000..993fa8a --- /dev/null +++ b/client/src/features/textWrap/breakpoints/tcCandidates.ts @@ -0,0 +1,78 @@ +import type { Breakpoint } from './types'; +import type { Token } from '../core/types'; + +type TcCandidateConfig = { + tcPunctuations: string[]; + balanceRange: number; +}; + +/** + * 计算 BALANCE 的 idealPos 列表(确定性简化版)。 + * + * - N=tokens.length + * - targetLines=min(maxLines,N) + * - lineIndex in 1..targetLines-1: + * idealPos=round(N*lineIndex/targetLines) + */ +export function computeTcIdealPositions(tokens: Token[], maxLines: number): number[] { + const n = tokens.length; + const targetLines = Math.max(1, Math.min(maxLines | 0, n)); + const ideals: number[] = []; + + for (let lineIndex = 1; lineIndex <= targetLines - 1; lineIndex++) { + const idealPos = Math.round((n * lineIndex) / targetLines); + ideals.push(idealPos); + } + + // 去重并排序(稳定) + ideals.sort((a, b) => a - b); + return ideals.filter((v, idx) => idx === 0 || v !== ideals[idx - 1]); +} + +/** + * TC 候选断点生成: + * - 标点后:kind=PUNCT,priority=30 + * - 空格后:kind=SPACE,priority=20 + * - BALANCE:kind=BALANCE,priority=5(围绕 idealPos ± balanceRange) + * + * 注意:BALANCE 断点允许生成在短语 span 内,是否可用交给评分阶段强惩罚淘汰。 + */ +export function generateTcCandidates(tokens: Token[], maxLines: number, config: TcCandidateConfig): { candidates: Breakpoint[]; idealPositions: number[] } { + const n = tokens.length; + if (n <= 1) return { candidates: [], idealPositions: [] }; + + const punctSet = new Set(config.tcPunctuations); + const candidates: Breakpoint[] = []; + + // PUNCT / SPACE(扫描 token) + for (let i = 0; i < n; i++) { + const t = tokens[i]?.text ?? ''; + const pos = i + 1; + + // 只允许行内断点 + if (pos < 1 || pos > n - 1) continue; + + if (punctSet.has(t)) { + candidates.push({ pos, kind: 'PUNCT', priority: 30 }); + } + + if (t === ' ') { + candidates.push({ pos, kind: 'SPACE', priority: 20 }); + } + } + + // BALANCE + const idealPositions = computeTcIdealPositions(tokens, maxLines); + const range = Math.max(0, config.balanceRange | 0); + if (range > 0 && idealPositions.length > 0) { + for (const ideal of idealPositions) { + for (let pos = ideal - range; pos <= ideal + range; pos++) { + if (pos < 1 || pos > n - 1) continue; + candidates.push({ pos, kind: 'BALANCE', priority: 5 }); + } + } + } + + return { candidates, idealPositions }; +} + diff --git a/client/src/features/textWrap/breakpoints/types.ts b/client/src/features/textWrap/breakpoints/types.ts new file mode 100644 index 0000000..15cfe98 --- /dev/null +++ b/client/src/features/textWrap/breakpoints/types.ts @@ -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; +}; + diff --git a/client/src/features/textWrap/core/__tests__/core-contract.test.ts b/client/src/features/textWrap/core/__tests__/core-contract.test.ts new file mode 100644 index 0000000..a450255 --- /dev/null +++ b/client/src/features/textWrap/core/__tests__/core-contract.test.ts @@ -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); + }); +}); + diff --git a/client/src/features/textWrap/core/compare.ts b/client/src/features/textWrap/core/compare.ts new file mode 100644 index 0000000..fa8687d --- /dev/null +++ b/client/src/features/textWrap/core/compare.ts @@ -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; +} + diff --git a/client/src/features/textWrap/core/enKeyword.ts b/client/src/features/textWrap/core/enKeyword.ts new file mode 100644 index 0000000..f34c4cf --- /dev/null +++ b/client/src/features/textWrap/core/enKeyword.ts @@ -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): Set { + 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): 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): boolean { + const a = normalizeENKeyword(tokenText, config); + const b = normalizeENKeyword(keyword, config); + if (!a || !b) return false; + return a === b; +} + diff --git a/client/src/features/textWrap/core/index.ts b/client/src/features/textWrap/core/index.ts new file mode 100644 index 0000000..e022bfc --- /dev/null +++ b/client/src/features/textWrap/core/index.ts @@ -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'; + diff --git a/client/src/features/textWrap/core/joinTokens.ts b/client/src/features/textWrap/core/joinTokens.ts new file mode 100644 index 0000000..f0a62e1 --- /dev/null +++ b/client/src/features/textWrap/core/joinTokens.ts @@ -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; +} + diff --git a/client/src/features/textWrap/core/normalizeWhitespace.ts b/client/src/features/textWrap/core/normalizeWhitespace.ts new file mode 100644 index 0000000..2a260d3 --- /dev/null +++ b/client/src/features/textWrap/core/normalizeWhitespace.ts @@ -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 }; +} + diff --git a/client/src/features/textWrap/core/tokenizeEN.ts b/client/src/features/textWrap/core/tokenizeEN.ts new file mode 100644 index 0000000..601831f --- /dev/null +++ b/client/src/features/textWrap/core/tokenizeEN.ts @@ -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; +} + diff --git a/client/src/features/textWrap/core/types.ts b/client/src/features/textWrap/core/types.ts new file mode 100644 index 0000000..0492943 --- /dev/null +++ b/client/src/features/textWrap/core/types.ts @@ -0,0 +1,40 @@ +/** + * Text Wrap - core-contract + * + * 本文件定义“跨端一致的基础口径”所需的最小类型集合。 + * 注意:这里的索引(start/end)默认以 normalizedText(归一化后的文本)为基准。 + */ + +export type Lang = 'TC' | 'EN'; + +export type WhitespacePolicy = 'NORMALIZE' | 'PRESERVE'; + +export type Token = { + /** token 的原始文本(EN:词;TC:字符簇) */ + text: string; + /** token 在 normalizedText 中的起始索引(包含) */ + start: number; + /** token 在 normalizedText 中的结束索引(不包含) */ + end: number; +}; + +export type NormalizeWhitespaceResult = { + /** + * 归一化后的文本。 + * - NORMALIZE:折叠连续空白为单空格,并去首尾空白 + * - PRESERVE:仅去首尾空白(内部空白保持原样) + */ + normalizedText: string; + /** 是否发生过“空白折叠/trim/非单空格分隔”等(用于后续 meta 打点) */ + hadMultiWhitespace: boolean; +}; + +export type CoreConfig = { + whitespacePolicy: WhitespacePolicy; + /** + * EN 关键词命中:两端可剥离的常见标点集合(必须全端一致)。 + * 来源建议与默认值参考:`设计说明文档/文档换行算法.md`(v1.2.1)2.3.1A-1 + */ + punctuationStripSetEN: string[]; +}; + diff --git a/client/src/features/textWrap/golden/__tests__/golden.test.ts b/client/src/features/textWrap/golden/__tests__/golden.test.ts new file mode 100644 index 0000000..a60f796 --- /dev/null +++ b/client/src/features/textWrap/golden/__tests__/golden.test.ts @@ -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); + }); +}); + diff --git a/client/src/features/textWrap/golden/fixtures.ts b/client/src/features/textWrap/golden/fixtures.ts new file mode 100644 index 0000000..2a914fc --- /dev/null +++ b/client/src/features/textWrap/golden/fixtures.ts @@ -0,0 +1,56 @@ +export type GoldenCase = { + id: string; + text: string; + lang: 'TC' | 'EN'; + context: 'APP' | 'WIDGET'; + availableWidth: number; + maxLines: number; + expected: { lines: string[]; wrappedText: string }; +}; + +/** + * Golden fixtures(首版最小可运行集) + * + * 说明: + * - APP:测量 mock=string.length,因此 availableWidth 也是“字符数单位” + * - WIDGET:widthMode=APPROX,因此 availableWidth 是“token 数单位” + */ +export const GOLDEN_CASES: GoldenCase[] = [ + { + id: 'en_app_simple_2lines', + text: 'I am so tired', + lang: 'EN', + context: 'APP', + availableWidth: 7, + maxLines: 2, + expected: { lines: ['I am so', 'tired'], wrappedText: 'I am so\ntired' }, + }, + { + id: 'en_app_punct_keyword', + text: 'but, still ok', + lang: 'EN', + context: 'APP', + availableWidth: 9, + maxLines: 2, + expected: { lines: ['but,', 'still ok'], wrappedText: 'but,\nstill ok' }, + }, + { + id: 'tc_app_punct_2lines', + text: '我好累,😮‍💨', + lang: 'TC', + context: 'APP', + availableWidth: 6, + maxLines: 2, + expected: { lines: ['我好累,', '😮‍💨'], wrappedText: '我好累,\n😮‍💨' }, + }, + { + id: 'tc_widget_approx_2lines', + text: '我好累', + lang: 'TC', + context: 'WIDGET', + availableWidth: 2, + maxLines: 2, + expected: { lines: ['我', '好累'], wrappedText: '我\n好累' }, + }, +]; + diff --git a/client/src/features/textWrap/grapheme/__tests__/segmentGraphemes.test.ts b/client/src/features/textWrap/grapheme/__tests__/segmentGraphemes.test.ts new file mode 100644 index 0000000..912ad6f --- /dev/null +++ b/client/src/features/textWrap/grapheme/__tests__/segmentGraphemes.test.ts @@ -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); + } + }); +}); + diff --git a/client/src/features/textWrap/grapheme/index.ts b/client/src/features/textWrap/grapheme/index.ts new file mode 100644 index 0000000..2441355 --- /dev/null +++ b/client/src/features/textWrap/grapheme/index.ts @@ -0,0 +1,9 @@ +export type { + GraphemeSegmentationMeta, + GraphemeSegmentationMode, + GraphemeSegmentationResult, + GraphemeSegmentationStrategy, +} from './types'; + +export { segmentGraphemes } from './segmentGraphemes'; + diff --git a/client/src/features/textWrap/grapheme/segmentGraphemes.ts b/client/src/features/textWrap/grapheme/segmentGraphemes.ts new file mode 100644 index 0000000..11d6a12 --- /dev/null +++ b/client/src/features/textWrap/grapheme/segmentGraphemes.ts @@ -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 } }; +} + diff --git a/client/src/features/textWrap/grapheme/strategies/fallback.ts b/client/src/features/textWrap/grapheme/strategies/fallback.ts new file mode 100644 index 0000000..51fdbd6 --- /dev/null +++ b/client/src/features/textWrap/grapheme/strategies/fallback.ts @@ -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; +} + diff --git a/client/src/features/textWrap/grapheme/strategies/intlSegmenter.ts b/client/src/features/textWrap/grapheme/strategies/intlSegmenter.ts new file mode 100644 index 0000000..cd7d9bc --- /dev/null +++ b/client/src/features/textWrap/grapheme/strategies/intlSegmenter.ts @@ -0,0 +1,28 @@ +/** + * Intl.Segmenter 策略(优先) + * + * 注意:不同 JS 引擎/版本对 Intl.Segmenter 的支持可能不同,调用方必须捕获异常并降级。 + */ + +export function canUseIntlSegmenter(): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Seg = (globalThis as any)?.Intl?.Segmenter; + return typeof Seg === 'function'; +} + +export function segmentWithIntlSegmenter(text: string): string[] { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Segmenter = (globalThis as any).Intl.Segmenter as new (locales?: string | string[], options?: any) => any; + + // 用 zh-Hant 仅用于选择合适的 locale;grapheme 分割应与语言本身关系不大,但保持固定输入更易对齐跨端。 + const seg = new Segmenter('zh-Hant', { granularity: 'grapheme' }); + const it = seg.segment(text); + + const clusters: string[] = []; + for (const part of it) { + // part: { segment: string, index: number, input: string, isWordLike?: boolean } + clusters.push(part.segment); + } + return clusters; +} + diff --git a/client/src/features/textWrap/grapheme/types.ts b/client/src/features/textWrap/grapheme/types.ts new file mode 100644 index 0000000..7d3f8f6 --- /dev/null +++ b/client/src/features/textWrap/grapheme/types.ts @@ -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; +}; + diff --git a/client/src/features/textWrap/index.ts b/client/src/features/textWrap/index.ts new file mode 100644 index 0000000..5ed3db6 --- /dev/null +++ b/client/src/features/textWrap/index.ts @@ -0,0 +1,4 @@ +export type { WrapTextConstraints, WrapTextInput, WrapTextMeta, WrapTextOutput } from './types'; + +export { wrapText } from './wrapText'; + diff --git a/client/src/features/textWrap/measure/__tests__/widthMeasurement.test.ts b/client/src/features/textWrap/measure/__tests__/widthMeasurement.test.ts new file mode 100644 index 0000000..aa3d060 --- /dev/null +++ b/client/src/features/textWrap/measure/__tests__/widthMeasurement.test.ts @@ -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); + }); +}); + diff --git a/client/src/features/textWrap/measure/cache.ts b/client/src/features/textWrap/measure/cache.ts new file mode 100644 index 0000000..26717af --- /dev/null +++ b/client/src/features/textWrap/measure/cache.ts @@ -0,0 +1,62 @@ +/** + * Text Wrap - width-measurement 缓存 + * + * 要求: + * - 模块级常驻缓存(跨调用复用) + * - 有容量上限(避免内存无限增长) + * - Key 必须确定性(由上层拼接传入) + * + * 说明: + * - 这里实现一个最小 LRU:Map 维护插入顺序;get 时“刷新”为最新。 + * - 缓存 value 允许是 Promise,以便并发请求去重(同 key 只测一次)。 + */ + +export class LruCache { + private readonly maxSize: number; + private readonly map: Map; + + 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; + } +} + diff --git a/client/src/features/textWrap/measure/errors.ts b/client/src/features/textWrap/measure/errors.ts new file mode 100644 index 0000000..c15387c --- /dev/null +++ b/client/src/features/textWrap/measure/errors.ts @@ -0,0 +1,19 @@ +/** + * Text Wrap - width-measurement 错误定义 + * + * 约束:fontSpec 缺字段必须直接报错(简体中文),禁止隐式默认值。 + */ + +export class MissingFontSpecError extends Error { + readonly name = 'MissingFontSpecError'; + + constructor(message: string) { + super(message); + } +} + +export function buildMissingFontSpecMessage(missingFields: string[]): string { + const fields = missingFields.join(', '); + return `fontSpec 缺少必填字段:${fields}。请在调用 wrapText() 时补齐 fontSpec(fontFamily/fontWeight/fontSize),禁止在测量层使用隐式默认值。`; +} + diff --git a/client/src/features/textWrap/measure/fontSpecKey.ts b/client/src/features/textWrap/measure/fontSpecKey.ts new file mode 100644 index 0000000..32d5b29 --- /dev/null +++ b/client/src/features/textWrap/measure/fontSpecKey.ts @@ -0,0 +1,32 @@ +import type { FontSpec } from './types'; +import { MissingFontSpecError, buildMissingFontSpecMessage } from './errors'; + +/** + * 校验 fontSpec 必填字段。 + * 约束:缺失字段必须报错(简体中文),避免跨端漂移。 + */ +export function assertFontSpecComplete(fontSpec: Partial | 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 | undefined | null): string { + assertFontSpecComplete(fontSpec); + return `${fontSpec.fontFamily}|${fontSpec.fontWeight}|${String(fontSpec.fontSize)}`; +} + diff --git a/client/src/features/textWrap/measure/index.ts b/client/src/features/textWrap/measure/index.ts new file mode 100644 index 0000000..47156f5 --- /dev/null +++ b/client/src/features/textWrap/measure/index.ts @@ -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'; + diff --git a/client/src/features/textWrap/measure/measureSliceWidthCached.ts b/client/src/features/textWrap/measure/measureSliceWidthCached.ts new file mode 100644 index 0000000..db4a8e8 --- /dev/null +++ b/client/src/features/textWrap/measure/measureSliceWidthCached.ts @@ -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>(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 | null; + measureWidthImpl?: MeasureWidthImpl; + widgetEnableMeasure?: boolean; + rawSeparators?: string[]; +}): Promise { + 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' } }; + } +} + diff --git a/client/src/features/textWrap/measure/measureWidthCached.ts b/client/src/features/textWrap/measure/measureWidthCached.ts new file mode 100644 index 0000000..fccf223 --- /dev/null +++ b/client/src/features/textWrap/measure/measureWidthCached.ts @@ -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>(2000); +const loggedMeasureFailures = new Set(); + +function buildTextKey(args: { contextProfile: ContextProfile; fontSpecKey: string; text: string }): string { + // key 拼接规则必须确定性:|| + return `${args.contextProfile}|${args.fontSpecKey}|${args.text}`; +} + +function isValidWidth(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v) && v >= 0; +} + +/** + * 测量文本宽度(带缓存)。 + * + * 约束: + * - 若启用测量(提供 measureWidthImpl),fontSpec 必须完整;缺字段直接报错(简体中文) + * - 若不启用测量(measureWidthImpl 缺失或 context=WIDGET 且明确不启用),进入 approx mode:width=null + */ +export async function measureWidthCached(args: { + text: string; + context: TextWrapContext; + contextProfile: ContextProfile; + fontSpec?: Partial | null; + measureWidthImpl?: MeasureWidthImpl; + /** WIDGET 场景是否启用测量;默认 false(未启用即 WIDTH_UNKNOWN) */ + widgetEnableMeasure?: boolean; +}): Promise { + 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' } }; + } +} + diff --git a/client/src/features/textWrap/measure/measureWidthImpl.ts b/client/src/features/textWrap/measure/measureWidthImpl.ts new file mode 100644 index 0000000..5d70270 --- /dev/null +++ b/client/src/features/textWrap/measure/measureWidthImpl.ts @@ -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; +}> { + // 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 { + return { + fontFamily: fontSpec.fontFamily, + fontSize: fontSpec.fontSize, + fontWeight: fontSpec.fontWeight, + }; +} + +/** + * 默认测量函数(可替换/可注入)。 + * - width 约束设为极大值,避免自动换行影响“单行宽度”测量 + * - usePreciseWidth=true,取更精确的宽度(开销更大,但对本算法更稳定) + */ +export const defaultMeasureWidthImpl: MeasureWidthImpl = async ({ text, fontSpec }) => { + const TextSize = await loadReactNativeTextSize(); + if (!TextSize || typeof TextSize.measure !== 'function') { + // 典型原因:在 Expo Go 中运行,或没有使用包含该原生模块的 Development Build。 + // 这里抛出更明确的错误,方便上层捕获并在 meta.reason=MEASURE_FAILED 时看到根因。 + throw new Error( + [ + 'react-native-text-size 原生模块不可用:TextSize.measure 不是函数。', + '请确认你不是在 Expo Go 里运行;需要使用包含该原生模块的 Development Build(expo-dev-client / expo run:ios / EAS dev build)。', + ].join(' '), + ); + } + const res = await TextSize.measure({ + text, + width: 1_000_000_000, + usePreciseWidth: true, + allowFontScaling: true, + ...toTextSizeFontSpecs(fontSpec), + }); + return res.width; +}; + diff --git a/client/src/features/textWrap/measure/types.ts b/client/src/features/textWrap/measure/types.ts new file mode 100644 index 0000000..90922d1 --- /dev/null +++ b/client/src/features/textWrap/measure/types.ts @@ -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||` 或 `WIDGET|` + */ +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; + diff --git a/client/src/features/textWrap/overflow/__tests__/overflowFallback.test.ts b/client/src/features/textWrap/overflow/__tests__/overflowFallback.test.ts new file mode 100644 index 0000000..de33aa2 --- /dev/null +++ b/client/src/features/textWrap/overflow/__tests__/overflowFallback.test.ts @@ -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'); + }); +}); + diff --git a/client/src/features/textWrap/overflow/ellipsis.ts b/client/src/features/textWrap/overflow/ellipsis.ts new file mode 100644 index 0000000..6b3ca3c --- /dev/null +++ b/client/src/features/textWrap/overflow/ellipsis.ts @@ -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 { + const { input, text } = args; + if (!input.measure) return null; + + const enabled = + typeof input.measure.measureWidthImpl === 'function' && + (input.measure.context === 'APP' || input.measure.widgetEnableMeasure === true); + + if (!enabled) return null; + + const res = await measureWidthCached({ + text, + context: input.measure.context, + contextProfile: input.measure.contextProfile, + fontSpec: input.measure.fontSpec, + measureWidthImpl: input.measure.measureWidthImpl, + widgetEnableMeasure: input.measure.widgetEnableMeasure, + }); + + return res.width; +} + +/** + * 给最后一行加省略号,并在可测量时保证不超宽: + * - EN 回退单位:整词(token) + * - TC 回退单位:grapheme(token) + */ +export async function applyEllipsisToLastLine(args: { + input: ApplyOverflowFallbackInput; + baseLines: Array<{ start: number; end: number; text: string }>; +}): Promise<{ lines: string[] }> { + const { input } = args; + const maxLines = Math.max(1, input.maxLines | 0); + const lines = args.baseLines.slice(0, maxLines); + if (lines.length === 0) return { lines: [] }; + + const lastIdx = lines.length - 1; + const last = lines[lastIdx]!; + + // 先按规则清理,再拼接 ellipsisToken + const cleaned = cleanLineBeforeEllipsis(last.text, input.lang, input.tcPunctuations); + let candidateText = `${cleaned}${input.ellipsisToken}`; + + // 若无测量能力:直接输出(确定性) + const measured = await measureIfPossible({ text: candidateText, input }); + if (measured === null) { + const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text)); + return { lines: out }; + } + + // 有测量能力:若超宽则按 token 回退 + if (measured <= input.availableWidth) { + const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text)); + return { lines: out }; + } + + // 回退:逐步减少最后一行 token 数再加省略号 + let end = last.end; + const start = last.start; + let foundFit = false; + while (end > start) { + end -= 1; + const base = joinTokens(input.tokens, start, end); + const cleaned2 = cleanLineBeforeEllipsis(base, input.lang, input.tcPunctuations); + const nextCandidate = `${cleaned2}${input.ellipsisToken}`; + const w = await measureIfPossible({ text: nextCandidate, input }); + if (w !== null && w <= input.availableWidth) { + candidateText = nextCandidate; + foundFit = true; + break; + } + } + + // 若连“仅省略号”都无法满足宽度(极窄场景),也必须给出确定性输出:直接输出 ellipsisToken + // 注意:这可能仍然超宽,但已是最小可表达形式;上层可通过 meta 做治理。 + if (!foundFit) { + candidateText = input.ellipsisToken; + } + + const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text)); + return { lines: out }; +} + diff --git a/client/src/features/textWrap/overflow/fallback.ts b/client/src/features/textWrap/overflow/fallback.ts new file mode 100644 index 0000000..2d15d51 --- /dev/null +++ b/client/src/features/textWrap/overflow/fallback.ts @@ -0,0 +1,86 @@ +import type { Token } from '../core/types'; +import { joinTokens } from '../core/joinTokens'; + +import { applyEllipsisToLastLine } from './ellipsis'; +import type { ApplyOverflowFallbackInput, ApplyOverflowFallbackResult, PartialLayoutInput, PartialLayoutLine } from './types'; + +function nTokens(tokens: Token[]): number { + return Array.isArray(tokens) ? tokens.length : 0; +} + +function coversToEnd(partial: PartialLayoutInput | null | undefined, n: number): boolean { + if (!partial || !Array.isArray(partial.lines) || partial.lines.length === 0) return false; + const last = partial.lines[partial.lines.length - 1]; + return (last?.end ?? -1) === n; +} + +function buildLinesFromPartial(args: { + tokens: Token[]; + partial?: PartialLayoutInput | null; + maxLines: number; + lang: 'TC' | 'EN'; +}): Array<{ start: number; end: number; text: string }> { + const N = nTokens(args.tokens); + const maxLines = Math.max(1, args.maxLines | 0); + const rawSeparators = args.lang === 'TC' ? Array.from({ length: Math.max(0, N) }, () => '') : undefined; + + if (args.partial && Array.isArray(args.partial.lines) && args.partial.lines.length > 0) { + const out: Array<{ start: number; end: number; text: string }> = []; + for (const line of args.partial.lines.slice(0, maxLines)) { + const start = Math.max(0, line.start | 0); + const end = Math.min(N, Math.max(start, line.end | 0)); + const text = line.text ?? joinTokens(args.tokens, start, end, rawSeparators); + out.push({ start, end, text }); + } + return out; + } + + // 若没有 partial:把全文当单行 best-effort + return [{ start: 0, end: N, text: joinTokens(args.tokens, 0, N, rawSeparators) }]; +} + +function toWrapped(lines: string[]): { lines: string[]; wrappedText: string } { + const outLines = lines.filter((s) => s !== undefined) as string[]; + return { lines: outLines, wrappedText: outLines.join('\n') }; +} + +function toSingleLine(tokens: Token[]): { lines: string[]; wrappedText: string } { + const N = nTokens(tokens); + // 注意:这里不知道语言口径,因此 SYSTEM_DEFAULT 的单行文本在上层保证传入“原文”更稳妥。 + // 为保持最小可用,这里仍使用默认 joinTokens(EN=空格 join,TC=grapheme 之间可能会有空格)。 + const text = joinTokens(tokens, 0, N); + return { lines: [text], wrappedText: text }; +} + +export async function applyOverflowFallback(input: ApplyOverflowFallbackInput): Promise { + 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 } }; +} + diff --git a/client/src/features/textWrap/overflow/index.ts b/client/src/features/textWrap/overflow/index.ts new file mode 100644 index 0000000..81b7fc5 --- /dev/null +++ b/client/src/features/textWrap/overflow/index.ts @@ -0,0 +1,15 @@ +export type { + ApplyOverflowFallbackInput, + ApplyOverflowFallbackResult, + FallbackType, + OverflowMeasure, + OverflowMode, + OverflowReason, + OverflowType, + PartialLayoutInput, + PartialLayoutLine, + WrapTextMeta, +} from './types'; + +export { applyOverflowFallback } from './fallback'; + diff --git a/client/src/features/textWrap/overflow/types.ts b/client/src/features/textWrap/overflow/types.ts new file mode 100644 index 0000000..37c85d3 --- /dev/null +++ b/client/src/features/textWrap/overflow/types.ts @@ -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 | 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; +}; + diff --git a/client/src/features/textWrap/scoring/__tests__/scoringTiebreak.test.ts b/client/src/features/textWrap/scoring/__tests__/scoringTiebreak.test.ts new file mode 100644 index 0000000..dc1234c --- /dev/null +++ b/client/src/features/textWrap/scoring/__tests__/scoringTiebreak.test.ts @@ -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, b: Array): boolean { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) { + const av = a[i] as any; + const bv = b[i] as any; + if (av < bv) return true; + if (av > bv) return false; + } + return a.length < b.length; +} + +describe('textWrap scoring-tiebreak', () => { + it('EMOTION_SPLIT:拆分情绪短语 -> 强惩罚 + 追加 spanLen(方案 A)', () => { + const tokens: Token[] = [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)]; + const layoutCandidate = { + breaks: [3], // I am so | tired + lines: [ + { start: 0, end: 3, text: 'I am so', width: 10, tokenCount: 3, charCount: 0 }, + { start: 3, end: 4, text: 'tired', width: 10, tokenCount: 1, charCount: 0 }, + ], + }; + + const res = scoreLayout({ + tokens, + layoutCandidate, + lang: 'EN', + context: 'APP', + availableWidth: 100, + config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] }, + lexicons: { ...DEFAULT_LEXICONS, emotionPhrasesEN: ['so tired'] }, + debug: true, + }); + + // 触发强惩罚:-(P_EMOTION_SPLIT + spanLen) + const first = res.scoreBreakdown?.terms[0]; + expect(first?.key).toBe('EMOTION_SPLIT'); + expect(first?.delta).toBe(-(DEFAULT_WEIGHTS.P_EMOTION_SPLIT + 2)); + expect(res.flags.emotionSplit).toBe(true); + }); + + it('Shift/Accum/Self:EN 词两端带标点也应命中(全词等值匹配)', () => { + const tokens: Token[] = [t('but,', 0), t('still', 5), t('ok', 11)]; + const layoutCandidate = { + breaks: [1], // but, | still ok + lines: [ + // 把 width 设置到 idealWidth 上,避免 length 项干扰 debug Top-3 + { start: 0, end: 1, text: 'but,', width: 90, tokenCount: 1, charCount: 0 }, + { start: 1, end: 3, text: 'still ok', width: 90, tokenCount: 2, charCount: 0 }, + ], + }; + + const res = scoreLayout({ + tokens, + layoutCandidate, + lang: 'EN', + context: 'APP', + availableWidth: 100, // idealWidth=100*0.9=90 + config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] }, + lexicons: DEFAULT_LEXICONS, + debug: true, + }); + + const terms = res.scoreBreakdown?.terms ?? []; + expect(terms.some((x) => x.key === 'SHIFT_BREAK')).toBe(true); + expect(terms.some((x) => x.key === 'ACCUM_BREAK')).toBe(true); + }); + + it('10.2G:EmotionWord 与 Accum 同时命中 -> Accum 奖励减半(整数)', () => { + const tokens: Token[] = [t('already', 0), t('tired', 8)]; + const layoutCandidate = { + breaks: [1], // already | tired + lines: [ + { start: 0, end: 1, text: 'already', width: 90, tokenCount: 1, charCount: 0 }, + { start: 1, end: 2, text: 'tired', width: 90, tokenCount: 1, charCount: 0 }, + ], + }; + + const weights = { ...DEFAULT_WEIGHTS, R_EMOTION_TAIL: 10, R_ACCUM_BREAK: 41 }; // 41/2 -> 20(floor) + + const res = scoreLayout({ + tokens, + layoutCandidate, + lang: 'EN', + context: 'APP', + availableWidth: 100, + config: { weights, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] }, + lexicons: DEFAULT_LEXICONS, + debug: true, + }); + + const terms = res.scoreBreakdown?.terms ?? []; + // debug 可能截断为 Top-3,但应包含 EMOTION_TAIL 与 ACCUM_BREAK(顺序确定性) + const hasEmotion = terms.some((x) => x.key === 'EMOTION_TAIL' && x.delta === 10); + const hasAccumHalf = terms.some((x) => x.key === 'ACCUM_BREAK' && x.delta === Math.floor(41 / 2)); + expect(hasEmotion).toBe(true); + expect(hasAccumHalf).toBe(true); + }); + + it('tieKey:lastLineWidth 更大优先(取 -lastLineWidth)', () => { + const base = { + breaks: [2], + lines: [ + { start: 0, end: 2, text: 'a b', width: 50, tokenCount: 2, charCount: 0 }, + { start: 2, end: 3, text: 'c', width: 10, tokenCount: 1, charCount: 0 }, + ], + }; + + const scoredA = { score: 0, flags: {}, tieKey: [] as any }; + const scoredB = { score: 0, flags: {}, tieKey: [] as any }; + + const keyShort = buildTieKey({ + scoredLayout: scoredA as any, + layoutCandidate: base as any, + lang: 'EN', + context: 'APP', + tokenCount: 3, + availableWidth: 100, + idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, + }); + + const layoutLongTail = { + ...base, + lines: [ + base.lines[0]!, + { ...base.lines[1]!, width: 30 }, // lastLineWidth 更大 + ], + }; + const keyLong = buildTieKey({ + scoredLayout: scoredB as any, + layoutCandidate: layoutLongTail as any, + lang: 'EN', + context: 'APP', + tokenCount: 3, + availableWidth: 100, + idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, + }); + + // 更优的 tieKey 应“更小”(因为 lastLineWidth 取负) + expect(lexicographicLess(keyLong, keyShort)).toBe(true); + }); +}); + diff --git a/client/src/features/textWrap/scoring/index.ts b/client/src/features/textWrap/scoring/index.ts new file mode 100644 index 0000000..fd89f25 --- /dev/null +++ b/client/src/features/textWrap/scoring/index.ts @@ -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'; + diff --git a/client/src/features/textWrap/scoring/lexicons.ts b/client/src/features/textWrap/scoring/lexicons.ts new file mode 100644 index 0000000..89fbcdf --- /dev/null +++ b/client/src/features/textWrap/scoring/lexicons.ts @@ -0,0 +1,31 @@ +import type { Lexicons } from './types'; + +/** + * scoring-tiebreak 最小词表(首版写死客户端) + * + * 来源:`设计说明文档/文档换行算法.md v1.2.1` 第 7 节 + * 原则:少而准(后续通过打点迭代扩充) + */ +export const DEFAULT_LEXICONS: Readonly = 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 | null | undefined): Lexicons { + if (!overrides) return { ...DEFAULT_LEXICONS }; + return { ...DEFAULT_LEXICONS, ...overrides }; +} + diff --git a/client/src/features/textWrap/scoring/phraseMatch.ts b/client/src/features/textWrap/scoring/phraseMatch.ts new file mode 100644 index 0000000..db2b29d --- /dev/null +++ b/client/src/features/textWrap/scoring/phraseMatch.ts @@ -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); +} + diff --git a/client/src/features/textWrap/scoring/score.ts b/client/src/features/textWrap/scoring/score.ts new file mode 100644 index 0000000..a816441 --- /dev/null +++ b/client/src/features/textWrap/scoring/score.ts @@ -0,0 +1,367 @@ +import type { Lang, Token } from '../core/types'; +import { normalizeENKeyword } from '../core/index'; + +import type { LayoutCandidateLine, ScoreLayoutInput, ScoreTerm, ScoredLayout, Weights } from './types'; +import { findPhraseSpans, isSpanSplitByBreaks, preparePhraseTokens } from './phraseMatch'; +import { mergeLexicons } from './lexicons'; +import { mergeWeights } from './weights'; + +function asInt(n: number): number { + // 保证输出整数(避免后续误用浮点) + return n | 0; +} + +function absInt(n: number): number { + return n < 0 ? -n : n; +} + +function roundToInt(n: number): number { + return Math.round(n); +} + +function ratioToBp(ratio: number): number { + // 把 0.90 转为 900(bp=1/1000),用于尽量规避浮点带来的跨端差异 + return Math.round(ratio * 1000); +} + +function lineWidthOrApprox(line: LayoutCandidateLine, lang: Lang): number { + if (typeof line.width === 'number' && Number.isFinite(line.width)) return roundToInt(line.width); + // width unknown/approx:回退到 tokenCount / charCount(确定性) + return lang === 'EN' ? asInt(line.tokenCount) : asInt(line.charCount); +} + +function buildSet(arr: string[] | undefined, lang: Lang): Set { + 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, 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): boolean { + return punctSet.has(t); +} + +function computeEmotionTailFlags(tokens: Token[], lines: LayoutCandidateLine[], lang: Lang, emotionWordsSet: Set): boolean[] { + const flags: boolean[] = []; + if (emotionWordsSet.size === 0) return lines.map(() => false); + + for (const line of lines) { + const start = Math.max(0, line.start | 0); + const end = Math.max(start, line.end | 0); + const slice = tokens.slice(start, end); + + if (slice.length === 0) { + flags.push(false); + continue; + } + + if (lang === 'EN') { + const last = slice[slice.length - 1]?.text ?? ''; + flags.push(tokenHitSet(last, emotionWordsSet, 'EN')); + continue; + } + + // TC:“最后 2 个 grapheme 范围内命中” + const last1 = slice[slice.length - 1]?.text ?? ''; + const last2 = slice.length >= 2 ? slice[slice.length - 2]?.text ?? '' : ''; + flags.push(tokenHitSet(last1, emotionWordsSet, 'TC') || tokenHitSet(last2, emotionWordsSet, 'TC')); + } + + return flags; +} + +function limitBreakdownForDebug(terms: ScoreTerm[]): ScoreTerm[] { + // 裁决补充 10.4A:debug Top-3 按规则优先级(10.1 顺序)输出 + // 这里采用最稳妥的实现:直接取“按实现顺序生成的前 3 项”(确定性且满足优先级排序) + return terms.slice(0, 3); +} + +export function scoreLayout(input: ScoreLayoutInput): ScoredLayout { + const weights: Weights = mergeWeights(input.config.weights); + const lexicons = mergeLexicons(input.lexicons); + + const tokens = input.tokens ?? []; + const breaks = (input.layoutCandidate.breaks ?? []).slice().sort((a, b) => a - b); + const lines = input.layoutCandidate.lines ?? []; + + const terms: ScoreTerm[] = []; + let score = 0; + + const flags: ScoredLayout['flags'] = { + overflowed: Boolean(input.layoutCandidate.meta?.overflowed), + fallback: Boolean(input.layoutCandidate.meta?.fallback), + }; + + // ------------------------------ + // 10.1-1 情绪短语拆分惩罚(最高优先) + // ------------------------------ + { + let deltaEmotion = 0; + let deltaProtected = 0; + + const listEmotion = input.lang === 'EN' ? lexicons.emotionPhrasesEN : lexicons.emotionPhrasesTC; + const listProtected = lexicons.protectedPhrases ?? []; + + const pushSplit = (key: 'EMOTION_SPLIT' | 'PROTECTED_SPLIT', phrase: string, spanLen: number) => { + const base = key === 'EMOTION_SPLIT' ? weights.P_EMOTION_SPLIT : weights.P_PROTECTED_SPLIT; + const d = -asInt(base) - asInt(spanLen); // 方案 A:追加“被拆分短语长度”惩罚(更长短语更强保护) + if (key === 'EMOTION_SPLIT') deltaEmotion += d; + else deltaProtected += d; + + flags.emotionSplit = true; + // 记录 detail 便于治理 + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + terms.push({ key, delta: d, detail: { phrase, spanLen } }); + }; + + // emotion phrases + for (const phrase of listEmotion) { + const phraseTokens = preparePhraseTokens(phrase, input.lang); + const spans = findPhraseSpans(tokens, phraseTokens, input.lang); + for (const span of spans) { + if (isSpanSplitByBreaks(span, breaks)) pushSplit('EMOTION_SPLIT', phrase, span.length); + } + } + + // protected phrases + for (const phrase of listProtected) { + const phraseTokens = preparePhraseTokens(phrase, input.lang); + const spans = findPhraseSpans(tokens, phraseTokens, input.lang); + for (const span of spans) { + if (isSpanSplitByBreaks(span, breaks)) pushSplit('PROTECTED_SPLIT', phrase, span.length); + } + } + + // 注意:这里已经把 term 推入了 terms(保持“优先级最高的项”最先出现) + score += deltaEmotion + deltaProtected; + } + + // ------------------------------ + // 10.1-2 行超长惩罚 + 理想长度奖励 + // ------------------------------ + { + const idealBp = ratioToBp(input.config.idealWidthRatio[input.context]); + const idealWidth = roundToInt((input.availableWidth * idealBp) / 1000); + const minPreferredRatio = input.config.minPreferredRatio ?? 0.6; + const minPreferredWidth = roundToInt((idealWidth * ratioToBp(minPreferredRatio)) / 1000); + + let overMaxDelta = 0; + let idealDelta = 0; + let tooShortDelta = 0; + + for (const line of lines) { + const w = lineWidthOrApprox(line, input.lang); + + if (w > input.availableWidth) { + overMaxDelta -= asInt(weights.P_OVER_MAXLEN) * asInt(w - input.availableWidth); + } + + idealDelta -= absInt(w - idealWidth); + + if (w < minPreferredWidth) { + tooShortDelta -= asInt(weights.P_TOO_SHORT) * asInt(minPreferredWidth - w); + } + } + + if (overMaxDelta !== 0) terms.push({ key: 'OVER_MAXLEN', delta: overMaxDelta, detail: { availableWidth: input.availableWidth } }); + if (idealDelta !== 0) terms.push({ key: 'IDEAL_LEN', delta: idealDelta, detail: { idealWidth, idealBp } }); + if (tooShortDelta !== 0) terms.push({ key: 'TOO_SHORT', delta: tooShortDelta, detail: { minPreferredWidth } }); + + score += overMaxDelta + idealDelta + tooShortDelta; + } + + // ------------------------------ + // 10.1-3 widow(EN)/ 单字行(TC) + // ------------------------------ + { + const lastLine = lines[lines.length - 1]; + if (lastLine) { + const lastWidth = lineWidthOrApprox(lastLine, input.lang); + + if (input.lang === 'EN') { + const tokenCount = asInt(lastLine.tokenCount); + if (tokenCount === 1) { + const d = -asInt(weights.P_WIDOW_LINE); + terms.push({ key: 'WIDOW_LINE', delta: d }); + score += d; + + const start = Math.max(0, lastLine.start | 0); + const end = Math.max(start, lastLine.end | 0); + const only = tokens.slice(start, end)[0]?.text ?? ''; + const norm = normalizeENKeyword(only); + const widowMaxLen = asInt(input.config.widowMaxLen ?? 3); + if (norm.length > 0 && norm.length <= widowMaxLen) { + const d2 = -asInt(weights.P_WIDOW_WORD); + terms.push({ key: 'WIDOW_WORD', delta: d2, detail: { word: norm, widowMaxLen } }); + score += d2; + } + } + } else { + // TC:最后一行只有 1 个 grapheme(或 charCount<=1)视为“单字行” + const c = asInt(lastLine.charCount); + if (c <= 1) { + const d = -asInt(weights.P_WIDOW_LINE); + terms.push({ key: 'WIDOW_LINE', delta: d, detail: { tcSingleChar: true, lastWidth } }); + score += d; + } + } + } + } + + // ------------------------------ + // 10.2F TC 助词孤立(在 10.1-3 后落地,保证顺序确定性) + // ------------------------------ + if (input.lang === 'TC' && lines.length > 0) { + const particles = new Set(input.config.tcParticles ?? ['啊', '喔', '呢', '啦', '吗', '吧', '呀']); + const whitelist = new Set(input.config.tcParticleWhitelist ?? ['啊', '喔', '呢', '啦']); + + let delta = 0; + + for (const line of lines) { + const start = Math.max(0, line.start | 0); + const end = Math.max(start, line.end | 0); + const slice = tokens.slice(start, end); + if (slice.length === 0) continue; + + const first = slice[0]?.text ?? ''; + const last = slice[slice.length - 1]?.text ?? ''; + + const hitFirst = particles.has(first); + const hitLast = particles.has(last); + if (!hitFirst && !hitLast) continue; + + const base = asInt(weights.P_PARTICLE_ISO); + const half = Math.floor(base / 2); + + if (hitFirst) delta -= whitelist.has(first) ? half : base; + if (hitLast) delta -= whitelist.has(last) ? half : base; + } + + if (delta !== 0) { + terms.push({ key: 'PARTICLE_ISO', delta, detail: { particles: Array.from(particles), whitelist: Array.from(whitelist) } }); + score += delta; + } + } + + // ------------------------------ + // 10.1-4 标点断点奖励(TC) + // ------------------------------ + if (input.lang === 'TC') { + const puncts = new Set(input.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、']); + let delta = 0; + + for (const b of breaks) { + const prev = tokens[b - 1]?.text ?? ''; + if (isTcPunctuation(prev, puncts)) delta += asInt(weights.R_PUNCT_BREAK); + } + + if (delta !== 0) { + terms.push({ key: 'PUNCT_BREAK', delta, detail: { tcPunctuations: Array.from(puncts) } }); + score += delta; + } + } + + // ------------------------------ + // 10.1-5 Shift/Accum/Self 断点奖励(含 EN 行首/行尾感知) + // ------------------------------ + { + const shiftSet = buildSet(input.lang === 'EN' ? lexicons.shiftWordsEN : lexicons.shiftWordsTC, input.lang); + const accumSet = buildSet(input.lang === 'EN' ? lexicons.accumWordsEN : lexicons.accumWordsTC, input.lang); + const selfSet = buildSet(input.lang === 'EN' ? lexicons.selfWordsEN : lexicons.selfWordsTC, input.lang); + const emotionSet = buildSet(input.lang === 'EN' ? lexicons.emotionWordsEN : lexicons.emotionWordsTC, input.lang); + + const emotionTailByLine = computeEmotionTailFlags(tokens, lines, input.lang, emotionSet); + + // EmotionWord 落点强化(默认权重为 0,不影响结果;但用于实现 10.2G 的“Accum 减半”规则) + let emotionTailDelta = 0; + for (let i = 0; i < emotionTailByLine.length; i++) { + if (emotionTailByLine[i]) emotionTailDelta += asInt(weights.R_EMOTION_TAIL); + } + if (emotionTailDelta !== 0) { + terms.push({ key: 'EMOTION_TAIL', delta: emotionTailDelta }); + score += emotionTailDelta; + } + + let shiftDelta = 0; + let accumDelta = 0; + let selfDelta = 0; + + // 为 Accum 减半规则准备:把每个 break 映射到其左右行索引 + // 假设 lines 与 breaks 一一对应:breaks[i] 分隔 lines[i] 与 lines[i+1] + for (let i = 0; i < breaks.length; i++) { + const b = breaks[i]!; + const prevLast = tokens[b - 1]?.text ?? ''; + const nextFirst = tokens[b]?.text ?? ''; + + const hitShift = tokenHitSet(nextFirst, shiftSet, input.lang) || tokenHitSet(prevLast, shiftSet, input.lang); + const hitAccum = tokenHitSet(nextFirst, accumSet, input.lang) || tokenHitSet(prevLast, accumSet, input.lang); + const hitSelf = tokenHitSet(nextFirst, selfSet, input.lang) || tokenHitSet(prevLast, selfSet, input.lang); + + if (hitShift) shiftDelta += asInt(weights.R_SHIFT_BREAK); + if (hitSelf) selfDelta += asInt(weights.R_SELF_BREAK); + + if (hitAccum) { + // 10.2G:若同一行(或同一断点)同时满足 EmotionWord 强化与 Accum 奖励 -> Accum 减半 + const leftLineIdx = i; + const rightLineIdx = i + 1; + const emotionHitNearby = Boolean(emotionTailByLine[leftLineIdx]) || Boolean(emotionTailByLine[rightLineIdx]); + const base = asInt(weights.R_ACCUM_BREAK); + const reward = emotionHitNearby ? Math.floor(base / 2) : base; + accumDelta += reward; + } + } + + if (shiftDelta !== 0) { + terms.push({ key: 'SHIFT_BREAK', delta: shiftDelta }); + score += shiftDelta; + } + if (accumDelta !== 0) { + terms.push({ key: 'ACCUM_BREAK', delta: accumDelta }); + score += accumDelta; + } + if (selfDelta !== 0) { + terms.push({ key: 'SELF_BREAK', delta: selfDelta }); + score += selfDelta; + } + } + + // ------------------------------ + // 10.1-6 多行视觉均衡(尾行过短惩罚) + // ------------------------------ + { + const lastLine = lines[lines.length - 1]; + if (lastLine) { + const idealBp = ratioToBp(input.config.idealWidthRatio[input.context]); + const idealWidth = roundToInt((input.availableWidth * idealBp) / 1000); + const shortRatio = input.config.shortLastLineRatio ?? 0.5; + const shortThreshold = roundToInt((idealWidth * ratioToBp(shortRatio)) / 1000); + + const lastW = lineWidthOrApprox(lastLine, input.lang); + if (lastW < shortThreshold) { + const d = -asInt(weights.P_SHORT_LASTLINE); + terms.push({ key: 'SHORT_LASTLINE', delta: d, detail: { shortThreshold, lastW, idealWidth } }); + score += d; + } + } + } + + const scoreBreakdown = input.debug ? { total: score, terms: limitBreakdownForDebug(terms) } : undefined; + + // tieKey 在 tieKey.ts 单独构造(此处返回占位,调用方可覆盖) + const tieKey: Array = [ + flags.emotionSplit ? 1 : 0, + flags.overflowed ? 1 : 0, + // lastLineWidth/spread/idealDistance 等由 buildTieKey 计算;这里仅保证结构存在 + ]; + + return { score, flags, tieKey, scoreBreakdown }; +} + diff --git a/client/src/features/textWrap/scoring/tieKey.ts b/client/src/features/textWrap/scoring/tieKey.ts new file mode 100644 index 0000000..a651c6b --- /dev/null +++ b/client/src/features/textWrap/scoring/tieKey.ts @@ -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 { + 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, + ]; +} + diff --git a/client/src/features/textWrap/scoring/types.ts b/client/src/features/textWrap/scoring/types.ts new file mode 100644 index 0000000..247e51c --- /dev/null +++ b/client/src/features/textWrap/scoring/types.ts @@ -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; + scoreBreakdown?: ScoreBreakdown; +}; + +export type Weights = Record; + +export type Lexicons = { + emotionPhrasesTC: string[]; + emotionPhrasesEN: string[]; + protectedPhrases?: string[]; + shiftWordsTC: string[]; + shiftWordsEN: string[]; + accumWordsTC: string[]; + accumWordsEN: string[]; + selfWordsTC: string[]; + selfWordsEN: string[]; + emotionWordsTC?: string[]; + emotionWordsEN?: string[]; +}; + +export type ScoringConfig = { + weights: Weights; + idealWidthRatio: { APP: number; WIDGET: number }; + ellipsisToken: string; + /** TC 标点集合(用于 PUNCT_BREAK 奖励与行首标点检测) */ + tcPunctuations?: string[]; + /** TC 助词集合(用于 PARTICLE_ISO) */ + tcParticles?: string[]; + /** TC 语尾语助词白名单:触发 PARTICLE_ISO 时惩罚减半 */ + tcParticleWhitelist: string[]; + /** Widow 短词阈值(EN):len(word) <= widowMaxLen(默认 3) */ + widowMaxLen?: number; + /** 过短阈值:minPreferred = idealWidth * minPreferredRatio(默认 0.6) */ + minPreferredRatio?: number; + /** 尾行过短阈值:shortLastLine = idealWidth * shortLastLineRatio(默认 0.5) */ + shortLastLineRatio?: number; +}; + +export type ScoreLayoutInput = { + tokens: Token[]; + layoutCandidate: LayoutCandidate; + lang: Lang; + context: TextWrapContext; + /** 可用宽度(同 width 单位),必须由上游提供 */ + availableWidth: number; + config: ScoringConfig; + lexicons: Lexicons; + debug?: boolean; +}; + diff --git a/client/src/features/textWrap/scoring/weights.ts b/client/src/features/textWrap/scoring/weights.ts new file mode 100644 index 0000000..025e5ab --- /dev/null +++ b/client/src/features/textWrap/scoring/weights.ts @@ -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 | null | undefined): Weights { + if (!overrides) return { ...DEFAULT_WEIGHTS }; + return { ...DEFAULT_WEIGHTS, ...overrides }; +} + diff --git a/client/src/features/textWrap/searchApp/__tests__/searchApp.test.ts b/client/src/features/textWrap/searchApp/__tests__/searchApp.test.ts new file mode 100644 index 0000000..0fefc30 --- /dev/null +++ b/client/src/features/textWrap/searchApp/__tests__/searchApp.test.ts @@ -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'); + }); +}); + diff --git a/client/src/features/textWrap/searchApp/constraints.ts b/client/src/features/textWrap/searchApp/constraints.ts new file mode 100644 index 0000000..0ffdbbd --- /dev/null +++ b/client/src/features/textWrap/searchApp/constraints.ts @@ -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; +} + diff --git a/client/src/features/textWrap/searchApp/dpTopK.ts b/client/src/features/textWrap/searchApp/dpTopK.ts new file mode 100644 index 0000000..d218d0e --- /dev/null +++ b/client/src/features/textWrap/searchApp/dpTopK.ts @@ -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 | 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(); + 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 { + const cfg = mergeConfig(input.config ?? null); + const tokens = input.tokens ?? []; + const lang = input.lang; + const n = tokens.length; + + if (isTooLong(tokens, lang, cfg.tooLongThresholds)) { + return { ok: false, reason: 'TOO_LONG', meta: { tokenCount: n } }; + } + + const maxLines = Math.max(1, input.maxLines | 0); + const availableWidth = input.availableWidth; + + const positions = uniqueSortedPositions(input.breakpoints ?? [], n); + const rawSep = rawSeparatorsForLang(tokens.length, lang); + + // dp[pos][linesUsed] -> PartialLayout[] + const dp: PartialLayout[][][] = Array.from({ length: n + 1 }, () => + Array.from({ length: maxLines + 1 }, () => []) + ); + + dp[0][0] = [ + { + pos: 0, + breaks: [], + lines: [], + score: 0, + tieKey: [0, 0, 0, 0, 0], // 空布局占位,后续会用 buildTieKey 覆盖 + }, + ]; + + const tcPunctuations = input.scoring.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、']; + + // DP 遍历顺序必须固定 + for (let pos = 0; pos <= n; pos++) { + for (let linesUsed = 0; linesUsed <= maxLines - 1; linesUsed++) { + const states = dp[pos][linesUsed]; + if (!states || states.length === 0) continue; + + // 枚举 nextPos:breakpoints 中 >pos 的位置(升序)+ N + const nextList: number[] = []; + for (const p of positions) if (p > pos) nextList.push(p); + if (n > pos) nextList.push(n); + + for (const state of states) { + for (const nextPos of nextList) { + if (nextPos <= pos) continue; // 禁止空行 + + // H6:TC 禁止“行首标点”(断点导致下一行第一个 token 为标点) + // 断点位置为 nextPos,因此要检查 tokens[nextPos] + if (lang === 'TC' && nextPos < n && isLineStartPunctTC(tokens, nextPos, tcPunctuations)) { + continue; + } + + const lineText = buildLineText(tokens, pos, nextPos, rawSep); + + const widthRes = await measureSliceWidthCached({ + tokens, + start: pos, + end: nextPos, + context: 'APP', + contextProfile: input.measure.contextProfile, + fontSpec: input.measure.fontSpec, + measureWidthImpl: input.measure.measureWidthImpl, + rawSeparators: rawSep, + }); + + if (widthRes.width === null) { + // App 搜索必须依赖宽度派;宽度不可用交给 overflow-fallback 统一处理 + return { + ok: false, + reason: widthRes.meta.reason === 'MEASURE_FAILED' ? 'MEASURE_FAILED' : 'WIDTH_UNKNOWN', + meta: { tokenCount: n }, + }; + } + + const lineWidth = widthRes.width; + if (isOverWidth(lineWidth, availableWidth)) continue; // H1 + + const tokenCount = nextPos - pos; + const charCount = lang === 'TC' ? tokenCount : 0; + + const newLine = { + start: pos, + end: nextPos, + text: lineText, + width: lineWidth, + tokenCount, + charCount, + }; + + const newLines = state.lines.concat([newLine]); + const newBreaks = nextPos === n ? state.breaks : state.breaks.concat([nextPos]); + + // 构造 layoutCandidate 并评分(为保证确定性,首版直接全量评分) + const layoutCandidate = { breaks: newBreaks, lines: newLines }; + + const scored = scoreLayout({ + tokens, + layoutCandidate: layoutCandidate as any, + lang, + context: 'APP', + availableWidth, + config: input.scoring.config, + lexicons: input.scoring.lexicons, + debug: Boolean(input.scoring.debug), + }); + + const tieKey = buildTieKey({ + scoredLayout: scored as any, + layoutCandidate: layoutCandidate as any, + lang, + context: 'APP', + tokenCount: n, + availableWidth, + idealWidthRatio: input.scoring.config.idealWidthRatio, + }) as number[]; + + const cand: PartialLayout = { + pos: nextPos, + breaks: newBreaks, + lines: newLines, + score: scored.score, + tieKey, + scoreTopTerms: scored.scoreBreakdown?.terms, + }; + + dp[nextPos][linesUsed + 1] = insertTopK(dp[nextPos][linesUsed + 1], cand, cfg.topK); + } + } + } + } + + // 结束选择 + const collect: PartialLayout[] = []; + if (input.lineMode === 'FIXED') { + collect.push(...dp[n][maxLines]); + } else { + for (let linesUsed = 1; linesUsed <= maxLines; linesUsed++) { + collect.push(...dp[n][linesUsed]); + } + } + + if (collect.length === 0) { + return { ok: false, reason: 'NO_CANDIDATE', meta: { tokenCount: n } }; + } + + // TopK 容器内本身已排序,但跨不同 linesUsed 需要再全局选最优 + collect.sort((a, b) => { + // 复用 insertTopK 的 compare 规则(在 topK.ts 内部)会更好,但这里直接再排序一次确保稳定 + if (a.score !== b.score) return b.score - a.score; + const na = a.tieKey; + const nb = b.tieKey; + const m = Math.min(na.length, nb.length); + for (let i = 0; i < m; i++) { + const av = na[i] ?? 0; + const bv = nb[i] ?? 0; + if (av < bv) return -1; + if (av > bv) return 1; + } + return na.length - nb.length; + }); + + const best = collect[0]!; + return { ok: true, bestLayout: buildBestLayout(best, Boolean(input.scoring.debug)) }; +} + diff --git a/client/src/features/textWrap/searchApp/index.ts b/client/src/features/textWrap/searchApp/index.ts new file mode 100644 index 0000000..b0fc0bf --- /dev/null +++ b/client/src/features/textWrap/searchApp/index.ts @@ -0,0 +1,4 @@ +export type { BestLayout, LineMode, SearchAppConfig, SearchAppInput, SearchAppResult, SearchFailureReason } from './types'; + +export { searchBestLayoutApp } from './dpTopK'; + diff --git a/client/src/features/textWrap/searchApp/topK.ts b/client/src/features/textWrap/searchApp/topK.ts new file mode 100644 index 0000000..ad1d203 --- /dev/null +++ b/client/src/features/textWrap/searchApp/topK.ts @@ -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; +} + diff --git a/client/src/features/textWrap/searchApp/types.ts b/client/src/features/textWrap/searchApp/types.ts new file mode 100644 index 0000000..c40016b --- /dev/null +++ b/client/src/features/textWrap/searchApp/types.ts @@ -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 | 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; +}; + +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[]; +}; + diff --git a/client/src/features/textWrap/searchWidget/__tests__/searchWidget.test.ts b/client/src/features/textWrap/searchWidget/__tests__/searchWidget.test.ts new file mode 100644 index 0000000..8f70bfd --- /dev/null +++ b/client/src/features/textWrap/searchWidget/__tests__/searchWidget.test.ts @@ -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'); + }); +}); + diff --git a/client/src/features/textWrap/searchWidget/beam.ts b/client/src/features/textWrap/searchWidget/beam.ts new file mode 100644 index 0000000..a201a8e --- /dev/null +++ b/client/src/features/textWrap/searchWidget/beam.ts @@ -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 | null): SearchWidgetConfig { + if (!overrides) return { ...DEFAULT_CONFIG }; + return { + beamK: overrides.beamK ?? DEFAULT_CONFIG.beamK, + expandM: overrides.expandM ?? DEFAULT_CONFIG.expandM, + tooLongThresholds: overrides.tooLongThresholds ?? DEFAULT_CONFIG.tooLongThresholds, + }; +} + +function uniqueSortedPositions(bps: Breakpoint[], n: number): number[] { + const set = new Set(); + for (const b of bps) { + const p = b.pos | 0; + if (p >= 1 && p <= n - 1) set.add(p); + } + const arr = Array.from(set); + arr.sort((a, b) => a - b); + return arr; +} + +function rawSeparatorsForLang(tokensLen: number, lang: 'TC' | 'EN'): string[] | undefined { + if (lang !== 'TC') return undefined; + // TC:默认拼接不插入额外空格;空格 token 自己决定展示 + return Array.from({ length: Math.max(0, tokensLen) }, () => ''); +} + +function buildLineText(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string { + return joinTokens(tokens, start, end, rawSeparators); +} + +function mergeApproxReason(a?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED', b?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED') { + if (a === 'MEASURE_FAILED' || b === 'MEASURE_FAILED') return 'MEASURE_FAILED'; + if (a === 'WIDTH_UNKNOWN' || b === 'WIDTH_UNKNOWN') return 'WIDTH_UNKNOWN'; + return undefined; +} + +function buildBestLayout(best: WidgetBeam, debug?: boolean): BestWidgetLayout { + const lines = best.lines.map((l) => l.text); + const wrappedText = lines.join('\n'); + + const reason = best.lines.reduce<'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | undefined>((acc, l) => { + return mergeApproxReason(acc, l.approxReason); + }, undefined); + + const meta = debug + ? { breaks: best.breaks, scoreTopTerms: best.scoreTopTerms, score: best.score, reason } + : { breaks: best.breaks, reason }; + + return { breaks: best.breaks, lines, wrappedText, meta }; +} + +export async function searchBestLayoutWidget(input: SearchWidgetInput): Promise { + const cfg = mergeConfig(input.config ?? null); + const tokens = input.tokens ?? []; + const lang = input.lang; + const n = tokens.length; + + if (isTooLong(tokens, lang, cfg.tooLongThresholds)) { + return { ok: false, reason: 'TOO_LONG', meta: { tokenCount: n } }; + } + + const maxLines = Math.max(1, input.maxLines | 0); + const availableWidth = input.availableWidth; + + const positions = uniqueSortedPositions(input.breakpoints ?? [], n); + const rawSep = rawSeparatorsForLang(tokens.length, lang); + + const tcPunctuations = input.scoring.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、']; + + // 初始 beams + let beams: WidgetBeam[] = [ + { pos: 0, breaks: [], lines: [], score: 0, tieKey: [0, 0, 0, 0, 0] }, + ]; + + for (let lineIndex = 1; lineIndex <= maxLines; lineIndex++) { + let newBeams: WidgetBeam[] = []; + + // 关键:保留已完成(pos==N)的 beams,避免“提前完成的解”在后续轮次被丢弃 + // 否则会错误地倾向“凑满 maxLines 行”的解,造成断句很怪(例如把“村莊”拆开) + for (const b of beams) { + if (b.pos === n) { + newBeams = insertBeamTopK(newBeams, b, cfg.beamK); + } + } + + // beams 顺序固定:按当前排序后的顺序扩展 + //(insertBeamTopK 会保持排序;这里再 sort 一次防御) + beams = beams.slice().sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + const na = a.tieKey; + const nb = b.tieKey; + const m = Math.min(na.length, nb.length); + for (let i = 0; i < m; i++) { + const av = na[i] ?? 0; + const bv = nb[i] ?? 0; + if (av < bv) return -1; + if (av > bv) return 1; + } + return na.length - nb.length; + }); + + for (const beam of beams) { + const pos = beam.pos; + if (pos >= n) continue; + + // nextPos:pos 升序 + N + const nextList: number[] = []; + for (const p of positions) if (p > pos) nextList.push(p); + if (n > pos) nextList.push(n); + + // expandM 裁剪:只取前 M 个(确定性:按 pos 升序),但必须保证 N(结束边界)始终可选 + // 否则会出现“明明一行就能结束,却被裁剪掉只能继续拆分”的怪异换行。 + const M = Math.max(1, cfg.expandM | 0); + let trimmed: number[]; + if (M === 1) { + trimmed = [n]; + } else { + const withoutN = nextList.filter((x) => x !== n); + trimmed = withoutN.slice(0, M - 1); + trimmed.push(n); + } + + for (const nextPos of trimmed) { + if (nextPos <= pos) continue; // 禁止空行 + + // H6:TC 禁止行首标点(断点导致下一行第一个 token 为标点) + if (lang === 'TC' && nextPos < n && isLineStartPunctTC(tokens, nextPos, tcPunctuations)) { + continue; + } + + const tokenCount = nextPos - pos; + const charCount = lang === 'TC' ? tokenCount : 0; + const lineText = buildLineText(tokens, pos, nextPos, rawSep); + + let lineWidth: number; + let isApprox = false; + let approxReason: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | undefined; + let widthTrusted = false; + + if (input.measure.widthMode === 'APPROX') { + isApprox = true; + approxReason = 'WIDTH_UNKNOWN'; + lineWidth = approxWidth(tokenCount, charCount, lang); + } else { + const widgetEnableMeasure = input.measure.widgetEnableMeasure ?? true; + const res = await measureSliceWidthCached({ + tokens, + start: pos, + end: nextPos, + context: 'WIDGET', + contextProfile: input.measure.contextProfile, + fontSpec: input.measure.fontSpec, + measureWidthImpl: input.measure.measureWidthImpl, + widgetEnableMeasure, + rawSeparators: rawSep, + }); + + if (res.width === null) { + isApprox = true; + approxReason = res.meta.reason ?? 'WIDTH_UNKNOWN'; + lineWidth = approxWidth(tokenCount, charCount, lang); + } else { + widthTrusted = true; + lineWidth = res.width; + } + } + + // 超宽过滤:仅当宽度可信时执行 + if (widthTrusted && lineWidth > availableWidth) continue; + + const newLine = { + start: pos, + end: nextPos, + text: lineText, + width: lineWidth, + tokenCount, + charCount, + isApprox, + approxReason, + }; + + const newLines = beam.lines.concat([newLine]); + const newBreaks = nextPos === n ? beam.breaks : beam.breaks.concat([nextPos]); + const layoutCandidate = { breaks: newBreaks, lines: newLines.map(({ isApprox: _a, approxReason: _b, ...rest }) => rest) }; + + const scored = scoreLayout({ + tokens, + layoutCandidate: layoutCandidate as any, + lang, + context: 'WIDGET', + availableWidth, + config: input.scoring.config, + lexicons: input.scoring.lexicons, + debug: Boolean(input.scoring.debug), + }); + + const tieKey = buildTieKey({ + scoredLayout: scored as any, + layoutCandidate: layoutCandidate as any, + lang, + context: 'WIDGET', + tokenCount: n, + availableWidth, + idealWidthRatio: input.scoring.config.idealWidthRatio, + }) as number[]; + + const cand: WidgetBeam = { + pos: nextPos, + breaks: newBreaks, + lines: newLines, + score: scored.score, + tieKey, + scoreTopTerms: scored.scoreBreakdown?.terms, + }; + + newBeams = insertBeamTopK(newBeams, cand, cfg.beamK); + } + } + + beams = newBeams; + if (beams.length === 0) break; + } + + const finished = beams.filter((b) => b.pos === n); + if (finished.length === 0) { + return { ok: false, reason: 'NO_CANDIDATE', meta: { tokenCount: n } }; + } + + finished.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + const na = a.tieKey; + const nb = b.tieKey; + const m = Math.min(na.length, nb.length); + for (let i = 0; i < m; i++) { + const av = na[i] ?? 0; + const bv = nb[i] ?? 0; + if (av < bv) return -1; + if (av > bv) return 1; + } + return na.length - nb.length; + }); + + const best = finished[0]!; + return { ok: true, bestLayout: buildBestLayout(best, Boolean(input.scoring.debug)) }; +} + diff --git a/client/src/features/textWrap/searchWidget/constraints.ts b/client/src/features/textWrap/searchWidget/constraints.ts new file mode 100644 index 0000000..b82f5d6 --- /dev/null +++ b/client/src/features/textWrap/searchWidget/constraints.ts @@ -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; +} + diff --git a/client/src/features/textWrap/searchWidget/index.ts b/client/src/features/textWrap/searchWidget/index.ts new file mode 100644 index 0000000..19a603d --- /dev/null +++ b/client/src/features/textWrap/searchWidget/index.ts @@ -0,0 +1,13 @@ +export type { + BestWidgetLayout, + SearchWidgetConfig, + SearchWidgetFailureReason, + SearchWidgetInput, + SearchWidgetMeasureInput, + SearchWidgetResult, + WidthMode, + WidgetBeam, +} from './types'; + +export { searchBestLayoutWidget } from './beam'; + diff --git a/client/src/features/textWrap/searchWidget/topK.ts b/client/src/features/textWrap/searchWidget/topK.ts new file mode 100644 index 0000000..a1c04cd --- /dev/null +++ b/client/src/features/textWrap/searchWidget/topK.ts @@ -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; +} + diff --git a/client/src/features/textWrap/searchWidget/types.ts b/client/src/features/textWrap/searchWidget/types.ts new file mode 100644 index 0000000..c10cf42 --- /dev/null +++ b/client/src/features/textWrap/searchWidget/types.ts @@ -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 | 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; +}; + +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[]; +}; + diff --git a/client/src/features/textWrap/types.ts b/client/src/features/textWrap/types.ts new file mode 100644 index 0000000..e037a69 --- /dev/null +++ b/client/src/features/textWrap/types.ts @@ -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 | null; + /** 可选注入测量实现(APP 场景强烈建议提供;WIDGET 默认不启用) */ + measureWidthImpl?: MeasureWidthImpl; + /** + * 测量缓存隔离 key(可选)。 + * 口径建议:`APP|ios|` / `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; +}; + diff --git a/client/src/features/textWrap/wrapText.ts b/client/src/features/textWrap/wrapText.ts new file mode 100644 index 0000000..77fe397 --- /dev/null +++ b/client/src/features/textWrap/wrapText.ts @@ -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 { + const lang = input.lang; + const context = input.context; + const configVersion = input.configVersion ?? 'v1'; + const overflowMode: WrapTextInput['overflowMode'] = + input.overflowMode ?? (context === 'WIDGET' ? 'ELLIPSIS' : 'CLIP'); + const lineMode = input.lineMode ?? 'AUTO'; + const debug = Boolean(input.debug); + + const { normalizedText } = normalizeWhitespace(input.text, 'NORMALIZE'); + const tokens = lang === 'EN' ? tokenizeEN(normalizedText) : tokenizeTC(normalizedText); + + // 断点候选 + const { breakpoints } = generateBreakpoints({ + tokens: tokens as any, + lang, + maxLines: input.maxLines, + constraints: input.constraints, + config: { tcMaxCandidateBreaks: 80, tcPunctuations: [',', '。', '!', '?', ';', ':', '、'], balanceRange: 3 }, + }); + + // scoring:protectedPhrases 从 constraints 注入 + const lexicons = { ...DEFAULT_LEXICONS, protectedPhrases: input.constraints?.protectedPhrases ?? DEFAULT_LEXICONS.protectedPhrases }; + const tcPunctuations: string[] = [',', '。', '!', '?', ';', ':', '、']; + const scoringConfig = { + weights: DEFAULT_WEIGHTS, + idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, + ellipsisToken: '…', + tcParticleWhitelist: [], + tcPunctuations, + }; + + // 搜索 + if (context === 'APP') { + const res = await searchBestLayoutApp({ + tokens, + lang, + breakpoints: breakpoints as any, + availableWidth: input.availableWidth, + maxLines: input.maxLines, + lineMode, + measure: { + contextProfile: input.contextProfile ?? 'APP', + fontSpec: input.fontSpec ?? null, + measureWidthImpl: input.measureWidthImpl, + }, + scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug }, + }); + + if (res.ok) { + const meta: WrapTextMeta = { configVersion, breaks: res.bestLayout.meta?.breaks, scoreTopTerms: res.bestLayout.meta?.scoreTopTerms }; + return { lines: res.bestLayout.lines, wrappedText: res.bestLayout.wrappedText, meta }; + } + + // APP:若测量失败/不可用,且调用方未要求 SYSTEM_DEFAULT,则尝试降级为“近似宽度 Beam” + // 目的:保证尽量产出带 \n 的 wrappedText(而不是完全依赖系统自动换行) + if (overflowMode !== 'SYSTEM_DEFAULT' && (res.reason === 'WIDTH_UNKNOWN' || res.reason === 'MEASURE_FAILED')) { + const fontSize = Number.isFinite((input.fontSpec as any)?.fontSize) ? Number((input.fontSpec as any)?.fontSize) : 22; + // 把像素宽度粗略换算为“token 容量”,只用于降级路径(确定性) + const approxCapacity = + lang === 'EN' + ? Math.max(1, Math.floor(input.availableWidth / Math.max(1, Math.round(fontSize * 0.55)))) + : Math.max(1, Math.floor(input.availableWidth / Math.max(1, Math.round(fontSize * 0.95)))); + + const approxRes = await searchBestLayoutWidget({ + tokens, + lang, + breakpoints: breakpoints as any, + availableWidth: approxCapacity, + maxLines: input.maxLines, + context: 'WIDGET', + measure: { widthMode: 'APPROX' }, + scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug }, + config: { beamK: 5, expandM: 12 }, + }); + + if (approxRes.ok) { + const meta: WrapTextMeta = { + configVersion, + breaks: approxRes.bestLayout.meta?.breaks, + scoreTopTerms: approxRes.bestLayout.meta?.scoreTopTerms, + reason: res.reason, + }; + return { lines: approxRes.bestLayout.lines, wrappedText: approxRes.bestLayout.wrappedText, meta }; + } + } + + // overflow/fallback(最终兜底) + const fb = await applyOverflowFallback({ + tokens, + lang, + context: 'APP', + availableWidth: input.availableWidth, + maxLines: input.maxLines, + overflowMode: overflowMode as any, + ellipsisToken: scoringConfig.ellipsisToken, + reason: res.reason, + partialLayout: null, + tcPunctuations: scoringConfig.tcPunctuations, + }); + + return { + lines: fb.lines, + wrappedText: fb.wrappedText, + meta: { configVersion, fallback_type: fb.meta.fallback_type, overflow_type: fb.meta.overflow_type, reason: fb.meta.reason }, + }; + } + + // WIDGET:默认 APPROX(单位由 availableWidth 决定),可在上层选择 MEASURE 并注入测量能力 + const resW = await searchBestLayoutWidget({ + tokens, + lang, + breakpoints: breakpoints as any, + availableWidth: input.availableWidth, + maxLines: input.maxLines, + context: 'WIDGET', + measure: { widthMode: 'APPROX' }, + scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug }, + }); + + if (resW.ok) { + const meta: WrapTextMeta = { + configVersion, + breaks: resW.bestLayout.meta?.breaks, + scoreTopTerms: resW.bestLayout.meta?.scoreTopTerms, + reason: resW.bestLayout.meta?.reason, + }; + return { lines: resW.bestLayout.lines, wrappedText: resW.bestLayout.wrappedText, meta }; + } + + const fb = await applyOverflowFallback({ + tokens, + lang, + context: 'WIDGET', + availableWidth: input.availableWidth, + maxLines: input.maxLines, + overflowMode: overflowMode as any, + ellipsisToken: scoringConfig.ellipsisToken, + reason: resW.reason, + partialLayout: null, + tcPunctuations: scoringConfig.tcPunctuations, + }); + + return { + lines: fb.lines, + wrappedText: fb.wrappedText, + meta: { configVersion, fallback_type: fb.meta.fallback_type, overflow_type: fb.meta.overflow_type, reason: fb.meta.reason }, + }; +} + diff --git a/server/celerybeat-schedule b/server/celerybeat-schedule index 653c867e370e7f63cc196839d1d362d11de90e36..7eb93532d16cf63dc2660fe44e7f05d3f535842c 100644 GIT binary patch delta 280 zcmZojXh_(=CcqWOz<-#3J^vlvu#JUoyuvMEb}UkzN~w7zMVYC^4J=a~m^S~H^<=CU z$}cF^O-fBH(alIrEGhx9rev^a^k5V3ktj(l&MwviYS9BJj7QSK;mpPUiiwj;oPmL% zXiA1?+mxUw-mDo?8a>Qf`i4_7q#>{bC|o-wNwXG6_OKR~PBxS`7BtH+%Yf+@V&buI zm|QAv&1f}wqr9+`C9{A*Nfpq+>_CG-pocj$#CeLIlJ(|$^1t{s?D(0L7)wi%<5Tkz elX6m1tk{?Z7)z=wLBjFzWvNBQnfZC~@zwyO3Rgb> delta 169 zcmZojXh_(=CcyQEf&Vc7dj31SZ#EX5=2dBSU}ceND@)BQDauSOZeW?(>|lZ_u!?bW zrmQF9WDj{6LA4CE3=U^5_E$`t?2?8&DmIf#<*gYFCU2A%meOYyFes@4n!wHg0X@v2 zA#8_IA9G{w(n3R*6V!*~Mz*thH4-$@#FH0>d&dkq?k2eGW Dtf(>R diff --git a/spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md b/spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md new file mode 100644 index 0000000..a9a2f7a --- /dev/null +++ b/spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md @@ -0,0 +1,162 @@ +# breakpoint-candidates(技术计划) + +## 1. 计划目标 + +基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,实现“候选断点生成与裁剪”模块,输出**可控规模、确定性排序**的 breakpoints 集合,保证: + +- EN/TC 断点生成口径一致(断点 `pos` 均是 token 边界索引) +- 同输入必定同输出(去重、排序、裁剪与过滤全流程确定性) +- 候选规模上限严格生效(尤其 TC) +- 支持约束过滤:`forbiddenBreakRanges`(必须)与 `protectedPhrases`(可选优化) + +本模块只产出 breakpoints,不做组合搜索与评分。 + +## 2. 默认技术决策(本计划采用) + +- **输出结构**:`Array<{ pos, kind, priority }>`,最终按 `pos` 升序 +- **去重策略**:同一 `pos` 若出现多个来源候选,保留 `priority` 更高者(priority 相同按 `kind` 固定序优先) +- **裁剪策略(TC)**:先按“候选重要性排序”截断到 `tcMaxCandidateBreaks`,再按 `pos` 升序输出 +- **过滤策略**: + - 必做:`forbiddenBreakRanges` 命中直接剔除 + - 可选优化:若已计算 `protectedPhrases` 的 span,可在生成阶段剔除 span 内断点(否则交给评分阶段强惩罚淘汰) + +## 3. 输入/输出与关键口径 + +### 3.1 输入(来自上游) + +- `tokens: Token[]` + - EN:WORD tokens(不包含 SPACE token) + - TC:grapheme cluster tokens(允许包含空格 cluster `" "`,用于 SPACE 断点) +- `lang: 'TC' | 'EN'` +- `maxLines: number` +- `constraints?: { protectedPhrases?: string[]; forbiddenBreakRanges?: Array<{ start: number; end: number }> }` +- `config: { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }` + +### 3.2 输出(确定性) + +- `breakpoints: Array<{ pos: number; kind: 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER'; priority: number }>` + - `pos`:token 边界索引,范围 `0..N` + - **最终输出必须按 `pos` 升序** +- `meta?: { pruned: boolean; originalCount: number; finalCount: number }` + +### 3.3 断点边界定义(统一口径) + +- 候选断点只生成在“行内断点”位置:`pos ∈ [1, N-1]` + - `pos=0` 与 `pos=N` 由搜索器作为“起止边界”处理(不作为候选断点输出) + +## 4. 生成规则(按语言) + +### 4.1 EN:词边界(kind=SPACE) + +#### 规则 + +- tokens 仅为 WORD,不生成 SPACE token +- 对每个词边界产生候选断点: + - 对 `i in 1..N-1` 生成 `pos=i, kind='SPACE'` +- `priority` 固定为基础值(建议 `priority=10`) + +#### 验收要点 + +- `"I am so tired"`(N=4)→ breakpoints.pos 必为 `[1,2,3]`(升序) + +### 4.2 TC:标点/空格/BALANCE + +#### 4.2.1 标点后断点(kind=PUNCT,最高优先级) + +- 若 `tokens[i].text` 属于 `tcPunctuations`: + - 生成 `pos=i+1, kind='PUNCT'` +- `priority` 建议最高(例如 `priority=30`) + +#### 4.2.2 空格后断点(kind=SPACE,中优先级) + +- 若 `tokens[i].text === ' '`: + - 生成 `pos=i+1, kind='SPACE'` +- `priority` 建议中等(例如 `priority=20`) + +> 注:若上游对 TC 也做了空白 NORMALIZE,则空格通常不会连写,断点仍保持确定性。 + +#### 4.2.3 BALANCE 断点(kind=BALANCE,低优先级) + +目的:在无标点时,仍在“接近理想位置”附近提供少量断点,提升可解性与观感。 + +**理想位置计算(确定性简化版)**: + +- `N = tokens.length` +- `targetLines = min(maxLines, N)`(至少 1,且不超过 N) +- 对 `lineIndex in 1..targetLines-1`: + - `idealPos = round((N * lineIndex) / targetLines)` + - 在区间 `[idealPos - balanceRange, idealPos + balanceRange]` 生成少量候选 `pos` + +**生成细则**: + +- 候选 `pos` 必须落在 `[1, N-1]` +- 去重前可以允许重复(后续统一去重) +- `priority` 建议最低(例如 `priority=5`) + +> 裁决补充口径:BALANCE 断点允许落在 emotionPhrase/protectedPhrases 的 span 内;是否可用由评分阶段强惩罚决定(本模块不做语义裁决)。 + +## 5. 过滤、去重、裁剪与排序(必须确定性) + +### 5.1 forbiddenBreakRanges 过滤(必须) + +- 若 `pos` 落在任一 `forbiddenBreakRanges` 的区间内(按项目约定:`start <= pos <= end` 或半开区间,必须写死一种),则剔除该 breakpoint +- 过滤必须发生在最终输出前,保证确定性 + +### 5.2 去重(必须) + +同一 `pos` 可能来自多个来源(例如 PUNCT 与 BALANCE): + +- 取 `priority` 更高者 +- `priority` 相同则按固定 kind 序:`PUNCT > SPACE > BALANCE > OTHER` + +### 5.3 TC 规模上限裁剪(必须) + +当 `lang=TC` 且候选数超过 `tcMaxCandidateBreaks`: + +1. 计算每个 pos 到最近 `idealPos` 的距离 `distToIdeal`(若无 idealPos 列表则设为大值) +2. 按以下 key 排序后截断(排序必须固定): + - `priority` 降序 + - `distToIdeal` 升序 + - `pos` 升序 +3. 取前 `tcMaxCandidateBreaks` + +最后再按 `pos` 升序输出(输出顺序固定)。 + +### 5.4 meta 输出 + +- `originalCount`:过滤/去重/裁剪前的候选数量 +- `finalCount`:最终输出数量 +- `pruned`:是否发生过裁剪(finalCount < originalCount) + +## 6. 测试计划(Vitest) + +### 6.1 EN 断点生成 + +- 输入 tokens=[I, am, so, tired] → pos=[1,2,3](确定性) + +### 6.2 TC 标点/空格断点 + +- `tokens=['我','好','累',',','😮‍💨']` 且 `tcPunctuations` 包含 `,` + - 必须包含 `pos=4(kind=PUNCT)` + +### 6.3 BALANCE 与裁剪 + +- 构造无标点长文本,balanceRange>0 且 `tcMaxCandidateBreaks` 很小 + - 验证裁剪后数量上限生效 + - 验证输出仍按 pos 升序 + +### 6.4 forbiddenBreakRanges + +- 给定 ranges,断言命中区间内的 pos 一律被剔除 + +### 6.5 确定性(关键) + +- 同输入多次调用 breakpoints 输出完全一致(包括 meta) + +## 7. 完成定义(DoD) + +- EN/TC 候选断点生成口径与裁剪规则写死并实现 +- 去重/排序/裁剪/过滤流程完全确定性 +- TC 上限 `tcMaxCandidateBreaks` 生效 +- 单测覆盖:EN/TC 基础、BALANCE、裁剪、forbiddenBreakRanges、确定性 + diff --git a/spec_kit/Text Wrap/modules/breakpoint-candidates/spec.md b/spec_kit/Text Wrap/modules/breakpoint-candidates/spec.md new file mode 100644 index 0000000..dcec3ee --- /dev/null +++ b/spec_kit/Text Wrap/modules/breakpoint-candidates/spec.md @@ -0,0 +1,51 @@ +# breakpoint-candidates(子模块规范) + +## 子模块名称 + +breakpoint-candidates(候选断点生成与裁剪) + +## 目标描述 + +基于 token 序列生成“可控规模、确定性排序”的候选断点集合(breakpoints),并应用去重、排序与约束过滤,确保后续搜索器复杂度可控且跨端一致。 + +本模块输出的是“断点候选集合”,不负责“组合搜索选最优”。 + +## 输入/输出定义 + +### 输入 + +- `tokens: Token[]` +- `lang: 'TC' | 'EN'` +- `maxLines: number` +- `constraints?: { protectedPhrases?: string[]; forbiddenBreakRanges?: Array<{ start: number; end: number }> }` +- `config: { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }` + +### 输出 + +- `breakpoints: Array<{ pos: number; kind: 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER'; priority: number }>` + - **pos**:token 边界索引(0..N) + - **排序**:按 `pos` 升序(最终输出必须确定性) +- `meta?: { pruned: boolean; originalCount: number; finalCount: number }` + +## 验收标准(可验证) + +- **EN 口径**: + - tokens 仅为 WORD(不生成 SPACE token),断点仅存在于词间 + - 每个词边界产生 `kind=SPACE` 的候选断点(按配置可做裁剪,但必须确定性) +- **TC 口径**: + - 标点后断点 `kind=PUNCT` 优先级最高 + - 空格后断点 `kind=SPACE` 次之 + - BALANCE 断点:围绕理想切分点附近生成少量断点(允许落在短语 span 内,是否可用交给评分惩罚) +- **去重/排序/过滤确定性**: + - 同一 `pos` 多来源断点:保留 priority 更高者 + - 输出按 `pos` 升序 + - `forbiddenBreakRanges` 命中者必定被剔除 +- **规模上限生效**: + - TC 输出候选断点数不超过 `tcMaxCandidateBreaks` + - 截断策略确定性(按 priority + 距离理想位置等固定规则) + +## 依赖与关联 + +- **依赖**:`core-contract`(token 与索引语义)、`grapheme-segmentation`(TC tokens) +- **被依赖**:`search-engine-app`、`search-engine-widget` + diff --git a/spec_kit/Text Wrap/modules/breakpoint-candidates/tasks.md b/spec_kit/Text Wrap/modules/breakpoint-candidates/tasks.md new file mode 100644 index 0000000..388e74f --- /dev/null +++ b/spec_kit/Text Wrap/modules/breakpoint-candidates/tasks.md @@ -0,0 +1,164 @@ +# breakpoint-candidates(任务清单) + +> 对应计划:`spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md` +> +> 状态含义:`[ ]` 未完成,`[x]` 已完成。 +> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。 + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未完成 + - `[x]` 已完成 +- 每个任务必须可独立验收(有明确产出与检查方式)。 +- 所有代码注释必须为简体中文,并把“去重/裁剪/排序/过滤”的**确定性口径**写死,避免后续模块漂移。 + +--- + +## 1. 前置对齐(口径必须写死) + +- [x] 1.1 明确 `pos` 的边界范围:仅输出 `pos ∈ [1, N-1]` + - **原因**:`pos=0/N` 属于搜索器的起止边界,不应作为候选断点 + - **验收**:单测覆盖 `N=0/1/2` 等边界输入,不会输出非法 pos。 + +- [x] 1.2 明确 `forbiddenBreakRanges` 的区间口径(写死一种) + - **本任务采用**:闭区间 `start <= pos && pos <= end` + - **验收**:单测能验证闭区间边界命中(start/end 两端都被剔除)。 + +--- + +## 2. 目录与代码骨架(客户端侧实现) + +- [x] 2.1 新建目录 `client/src/features/textWrap/breakpoints/` + - **产出**(建议文件): + - `types.ts`(Breakpoint/Config/Constraints) + - `generateBreakpoints.ts`(主入口,纯函数) + - `tcCandidates.ts`(TC:PUNCT/SPACE/BALANCE 生成) + - `enCandidates.ts`(EN:SPACE 断点生成) + - `filterAndDedup.ts`(过滤/去重/排序/裁剪) + - `__tests__/generateBreakpoints.test.ts` + - `index.ts`(统一导出) + - **验收**:目录存在,TS 可正常 import(不报路径错误)。 + +- [x] 2.2 定义最小类型集合(只覆盖本模块) + - **必须包含**: + - `Breakpoint = { pos: number; kind: 'PUNCT'|'SPACE'|'BALANCE'|'OTHER'; priority: number }` + - `BreakpointMeta = { pruned: boolean; originalCount: number; finalCount: number }` + - `Constraints = { forbiddenBreakRanges?: Array<{ start: number; end: number }>; protectedPhrases?: string[] }` + - `Config = { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }` + - **验收**:后续实现文件引用类型清晰,且不会引入无关依赖。 + +--- + +## 3. 断点生成(按语言) + +- [x] 3.1 EN 候选断点生成(kind=SPACE,priority 固定) + - **规则**: + - 对 `i in 1..N-1` 生成 `pos=i, kind='SPACE'` + - `priority=10`(写死) + - **验收**: + - tokens=[I, am, so, tired] → pos=[1,2,3] + +- [x] 3.2 TC:标点后断点(kind=PUNCT) + - **规则**: + - 若 `tokens[i].text ∈ tcPunctuations`,生成 `pos=i+1, kind='PUNCT', priority=30` + - **验收**: + - tokens=['我','好','累',',','😮‍💨'] → 包含 `pos=4(kind=PUNCT)` + +- [x] 3.3 TC:空格后断点(kind=SPACE) + - **规则**: + - 若 `tokens[i].text === ' '`,生成 `pos=i+1, kind='SPACE', priority=20` + - **验收**:构造含空格 tokens,断点生成稳定且不越界。 + +- [x] 3.4 TC:BALANCE 断点生成(kind=BALANCE) + - **规则**: + - `targetLines = min(maxLines, N)` + - 对 `lineIndex in 1..targetLines-1`: + - `idealPos = round((N * lineIndex) / targetLines)` + - 在 `[idealPos-balanceRange, idealPos+balanceRange]` 内生成 pos(裁剪到 `[1,N-1]`) + - `priority=5` + - **验收**: + - 无标点长文本:可生成接近理想位置的候选断点(数量受控、确定性)。 + +--- + +## 4. 过滤、去重、裁剪与最终排序(必须确定性) + +- [x] 4.1 forbiddenBreakRanges 过滤(闭区间) + - **规则**:命中任一 range 则剔除该 `pos` + - **验收**:range 边界 start/end 都会剔除。 + +- [x] 4.2 去重:同 pos 只保留一个 breakpoint + - **规则**: + - priority 更高者优先 + - priority 相同按 kind 固定序:`PUNCT > SPACE > BALANCE > OTHER` + - **验收**:构造同 pos 多来源候选,结果唯一且确定性。 + +- [x] 4.3 TC 裁剪:超过 `tcMaxCandidateBreaks` 时截断(确定性排序后截断) + - **排序 key(写死)**: + - priority 降序 + - distToIdeal 升序(到最近 idealPos 的距离;无 idealPos 时为大值) + - pos 升序 + - **验收**: + - 当候选数 > 上限时,finalCount==tcMaxCandidateBreaks + - 截断结果稳定(同输入同输出) + +- [x] 4.4 最终输出排序:按 `pos` 升序 + - **验收**:无论内部裁剪排序如何,最终输出始终 `pos` 升序。 + +- [x] 4.5 meta 输出 + - **规则**: + - `originalCount`:过滤/去重/裁剪前的候选数量 + - `finalCount`:最终输出数量 + - `pruned = finalCount < originalCount` + - **验收**:单测断言 meta 与候选数量一致。 + +--- + +## 5. 单元测试(Vitest) + +- [x] 5.1 新建 `generateBreakpoints.test.ts`,覆盖 EN 基础用例 + - **验收**:pos=[1,2,3] 且升序。 + +- [x] 5.2 覆盖 TC:PUNCT/SPACE/BALANCE 生成 + - **验收**:关键样例存在,且 BALANCE 不越界。 + +- [x] 5.3 覆盖 forbiddenBreakRanges(闭区间) + - **验收**:start/end 命中都剔除。 + +- [x] 5.4 覆盖去重与 kind 优先级 + - **验收**:同 pos 多候选时输出唯一且正确 kind。 + +- [x] 5.5 覆盖 TC 裁剪上限与确定性 + - **验收**: + - 数量上限严格生效 + - 同输入多次调用输出完全一致(包括 meta) + +--- + +## 6. 最终自检清单(合入前) + +- [x] 6.1 `npm test` 通过(包含本模块新增用例) + - **验收**:不影响现有测试文件。 + +- [x] 6.2 `npx tsc --noEmit` 通过(或项目既有 TS 检查命令通过) + - **验收**:无类型错误。 + +- [x] 6.3 注释与口径自检(简体中文) + - **检查点**: + - `pos` 边界范围 `[1,N-1]` + - forbiddenBreakRanges 闭区间口径 + - 去重优先级与裁剪排序 key 的固定顺序 + - **验收**:后续模块开发者只看代码也不会产生歧义。 + +--- + +## 7. 文档回写(任务清单执行完毕后必须做) + +- [x] 7.1 在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充执行状态 + - **建议写法**: + - 增加一行:`- **已完成编码(阶段性)**:breakpoint-candidates(候选断点生成与裁剪)` + - **验收**:overview 能反映该子模块已完成,便于全局追踪。 + diff --git a/spec_kit/Text Wrap/modules/core-contract/plan.md b/spec_kit/Text Wrap/modules/core-contract/plan.md new file mode 100644 index 0000000..b954594 --- /dev/null +++ b/spec_kit/Text Wrap/modules/core-contract/plan.md @@ -0,0 +1,111 @@ +# core-contract(技术计划) + +## 1. 计划目标 + +基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,落地跨端一致的“基础口径与契约”,为后续断点生成、搜索与评分提供稳定输入与确定性工具,确保: + +- EN/TC 的 **token 索引体系** 与断点 `pos` 语义固定 +- 文本 **可重组**:任意 `[start..end)` 区间可稳定还原为行文本 +- EN 关键词命中规则严格为 **全词等值匹配**(避免 substring 误伤) +- 空白归一化策略可配置但默认一致(推荐 NORMALIZE) +- 提供可复用的 **确定性比较工具**(用于 breaks 字典序/tieKey 比较) + +## 2. 默认技术决策(本计划采用) + +- **空白策略**:默认 `whitespacePolicy=NORMALIZE` + - 行为:折叠连续空白为 1 个空格、去首尾空白 + - 并在 meta(后续模块)中打点 `hadMultiWhitespace`(本模块先预留布尔返回位) +- **EN tokenize**:仅生成 WORD token(不生成 SPACE token),以空白分隔;标点按“极简派”保留在词内 +- **TC tokenize**:本模块只定义接口,具体分割由 `grapheme-segmentation` 提供 +- **EN 关键词命中**:`lowercase → strip 两端常见标点 → 等值比较`,禁止 contains/substring +- **确定性比较**:breaks 字典序比较按“逐项比较 + 公共前缀相同则更短者更小” + +## 3. 目录与产物 + +本子模块目录: + +- `spec_kit/Text Wrap/modules/core-contract/spec.md` +- `spec_kit/Text Wrap/modules/core-contract/plan.md`(本文) + +建议未来代码落位(实现阶段再定,不在本计划强制): + +- `client/src/features/textWrap/core/`(或 `client/src/utils/textWrap/`) + +## 4. 设计与实现要点(按落地顺序) + +### 4.1 文本预处理:normalizeWhitespace + +实现 `normalizeWhitespace(text) -> { normalizedText, hadMultiWhitespace }`: + +- 规则: + - 把任意连续空白(空格/制表/换行等)折叠为单个空格 + - 去除首尾空白 +- 注意: + - 该规范会改变输入文本;必须作为“算法契约”的一部分固定下来 + - 若后续产品需要保留原始空白,则走 `PRESERVE` 分支并输出 `rawSeparators`(见 4.3) + +### 4.2 EN tokenize:word tokens(极简派) + +实现 `tokenizeEN(normalizedText) -> tokens[]`: + +- 以空格分隔生成 token +- token 只包含 WORD,标点视为词内字符(例如 `tired.`、`Wait...`、`hello—world`、`don't` 都是单 token) +- 输出 token 的 `text/start/end`(start/end 为原始或 normalized 的字符区间,需固定口径;建议以 normalizedText 为基准) + +### 4.3 文本重组:joinTokens + +实现 `joinTokens(tokens, start, end, separators?) -> string`: + +- EN 默认:使用单空格 `" "` join `[start..end)` 的 token.text +- 若 `whitespacePolicy=PRESERVE`: + - 需要 `rawSeparators[i]` 表示 tokens[i] 与 tokens[i+1] 间的原始分隔符 + - 重组时按 separators 拼接(本计划仅定义接口与行为) + +### 4.4 EN 关键词命中:normalizeENKeyword + matchENKeyword + +实现: + +- `normalizeENKeyword(tokenText) -> string` + - `lowercase` + - `strip` 两端常见标点(集合需配置化并全端一致,默认参考文档:`, . ! ? : ; " ' … — – ( ) [ ] { }`) +- `matchENKeyword(tokenText, keyword) -> boolean` + - `normalizeENKeyword(tokenText) === normalizeENKeyword(keyword)` + - 禁止 `includes/contains` 类 substring 命中 + +### 4.5 断点/区间的索引契约 + +固化约定(后续模块必须复用,不得自行发挥): + +- token 索引:`tokens[0..N-1]` +- 断点 `pos`:位于 token 边界,切分为 `[0..pos)` 与 `[pos..N)` +- 行区间:`[start..end)` 表示 `tokens[start] ... tokens[end-1]` + +## 5. 回归用例与验证方式 + +### 5.1 必测示例(EN) + +- `"I am so tired"` → tokens=`[I, am, so, tired]` + - `pos=2` → `"I am"` / `"so tired"` +- 标点极简派: + - `"tired."` 为单 token +- 关键词命中: + - keyword=`"but"`:`"but,"` 命中;`"rebuttal"` 不命中 + +### 5.2 确定性检查 + +- 同一输入在同一配置下多次调用: + - `normalizedText`、`tokens[]`、`joinTokens()`、`matchENKeyword()` 输出完全一致 + +## 6. 风险与规避 + +- **start/end 索引口径漂移**:若不同端选择以原始 text 或 normalizedText 计数,可能导致 span 对不齐 + - 规避:本模块明确 start/end 以 normalizedText 为准(或在实现阶段统一选择一种并写入 README/注释) +- **标点集合不一致**:strip 集合若跨端不同会导致命中差异 + - 规避:将 `punctuationStripSetEN` 写入配置并版本化,禁止散落常量 + +## 7. 完成定义(DoD) + +- `core-contract/spec.md` 中定义的输入/输出与验收条目均有可运行的最小实现或可验证的约束说明 +- EN tokenize / 空白归一化 / 关键词命中 / breaks 字典序比较口径写清楚且可复现 +- 关键示例用例可在本地/CI 以单元测试或脚本方式验证(实现阶段落地) + diff --git a/spec_kit/Text Wrap/modules/core-contract/spec.md b/spec_kit/Text Wrap/modules/core-contract/spec.md new file mode 100644 index 0000000..0b81249 --- /dev/null +++ b/spec_kit/Text Wrap/modules/core-contract/spec.md @@ -0,0 +1,59 @@ +# core-contract(子模块规范) + +## 子模块名称 + +core-contract(核心口径与契约) + +## 目标描述 + +定义并固化跨端一致的“基础口径”,为后续断点生成、搜索与评分提供统一契约,避免实现偏差: + +- **索引体系**:EN/TC 的 token 与断点 `pos` 语义 +- **文本重组**:从 token 区间稳定重组回行文本 +- **规范化**:空白归一化策略(默认折叠空白、去首尾) +- **EN 关键词命中规则**:全词等值匹配(`lowercase → strip 两端常见标点 → 等值比较`),禁止 substring/contains +- **配置与版本**:`configVersion` 的语义与回溯字段;全端一致的默认值入口 +- **确定性比较**:layout tie-break 的字典序比较口径(作为后续模块复用工具) + +本模块不负责“换行搜索”,只负责**定义数据结构与基础函数**。 + +## 输入/输出定义 + +### 输入 + +- `text: string` +- `lang: 'TC' | 'EN'` +- `options?: { preserveRawSeparators?: boolean }` +- `config: { punctuationStripSetEN: string[]; whitespacePolicy: 'NORMALIZE' | 'PRESERVE' }` + +### 输出 + +- `normalizedText: string` +- `tokens: Array<{ text: string; start: number; end: number }>` + - EN:token 仅为 WORD(不产生 SPACE token;标点视为词内字符) + - TC:token 为 grapheme cluster(具体分割由 `grapheme-segmentation` 模块实现/提供) +- `rawSeparators?: string[]` + - 可选:当选择保留原始空白时,输出 token 间分隔符映射 +- 基础工具函数(逻辑输出): + - `joinTokens(start, end) -> string` + - `normalizeENKeyword(tokenText) -> string` + - `matchENKeyword(tokenText, keyword) -> boolean` + +## 验收标准(可验证) + +- **索引语义一致**: + - EN:`"I am so tired"` tokens=`[I, am, so, tired]`,断点 `pos=2` 必然切为 `"I am"` / `"so tired"` + - TC:断点 `pos` 表示在第 `pos` 个 grapheme 之前断开 +- **EN 标点极简派一致**: + - `"tired."` 作为一个 token;断点只允许在词与词之间 +- **EN 关键词命中无误伤**: + - keyword=`"but"`:`"but,"` 命中;`"rebuttal"` 不命中 +- **空白归一化确定性**: + - 输入含多空格/首尾空白时,输出 `normalizedText` 可预测且稳定 +- **工具函数确定性**:相同输入在多次调用与多端实现中输出一致 + +## 依赖与关联 + +- **被依赖**:`breakpoint-candidates`、`scoring-tiebreak`、`search-engine-*`、`overflow-fallback`、`integration` +- **依赖**:TC token 分割依赖 `grapheme-segmentation` + diff --git a/spec_kit/Text Wrap/modules/core-contract/tasks.md b/spec_kit/Text Wrap/modules/core-contract/tasks.md new file mode 100644 index 0000000..ff458fc --- /dev/null +++ b/spec_kit/Text Wrap/modules/core-contract/tasks.md @@ -0,0 +1,150 @@ +# core-contract(任务清单) + +> 对应计划:`spec_kit/Text Wrap/modules/core-contract/plan.md` +> +> 状态含义:`[ ]` 未完成,`[x]` 已完成。 +> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。 + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未完成 + - `[x]` 已完成 +- 每个任务必须可独立验收(有明确产出与检查方式)。 +- 涉及“口径”的任务,必须在代码注释中写清楚(简体中文),避免后续模块实现漂移。 + +--- + +## 1. 文档对齐(先把口径写死,避免实现漂移) + +- [x] 1.1 复核 `core-contract/spec.md` 与 `core-contract/plan.md` 的一致性 + - **检查点**: + - `whitespacePolicy`(NORMALIZE/PRESERVE)语义一致 + - EN token 规则为“极简派”(不拆标点、不生成 SPACE token) + - EN 关键词命中为“全词等值匹配”(禁止 substring) + - breaks 字典序比较规则清晰且无歧义 + - **验收**:两份文档无冲突描述;关键字段命名一致。 + +- [x] 1.2 明确 `start/end` 的索引口径(以 normalizedText 为基准)并写入代码注释与 README(如有) + - **原因**:跨端 span/调试定位会依赖该口径 + - **验收**:任意 token 的 `start/end` 都可映射到同一份文本基准(normalizedText)。 + +--- + +## 2. 目录与代码骨架(客户端侧优先落地) + +> 说明:当前仓库已有 `client/src/features/*` 结构,Text Wrap 建议也放到 `features/` 下,便于后续 Home/Widget 复用。 + +- [x] 2.1 新建目录 `client/src/features/textWrap/core/` + - **产出**(建议文件): + - `types.ts`:Token/Config/Options 类型 + - `normalizeWhitespace.ts` + - `tokenizeEN.ts` + - `joinTokens.ts` + - `enKeyword.ts`(normalizeENKeyword/matchENKeyword) + - `compare.ts`(breaks 字典序比较) + - `index.ts`(统一导出) + - **验收**:目录存在且可被 TS 正常 import(不报路径错误)。 + +- [x] 2.2 定义 `Token` 与基础配置类型(只含 core-contract 需要的字段) + - **要求**: + - `Token` 至少包含 `text/start/end` + - `CoreConfig` 至少包含 `whitespacePolicy` 与 `punctuationStripSetEN` + - **验收**:类型定义满足后续函数签名需要,且命名清晰。 + +--- + +## 3. 核心函数实现(纯函数 + 确定性) + +- [x] 3.1 实现 `normalizeWhitespace(text)`(默认 NORMALIZE) + - **规则**: + - 连续空白折叠为单个空格 + - 去除首尾空白 + - 返回 `hadMultiWhitespace`(用于后续 meta 打点) + - **验收**: + - 输入 `" a b \n c "` 输出 `"a b c"` + - `hadMultiWhitespace` 在出现折叠/trim 时为 true + +- [x] 3.2 实现 `tokenizeEN(normalizedText)`(极简派) + - **规则**: + - 以空格切分为 WORD tokens + - 标点作为 token.text 的一部分(不拆) + - 不生成 SPACE token + - **验收**: + - `"I am so tired"` → `[I, am, so, tired]` + - `"tired."` 为单 token + +- [x] 3.3 实现 `joinTokens(tokens, start, end, separators?)` + - **规则**: + - 默认用单空格 join `[start..end)` 的 token.text + - `start/end` 为半开区间,越界/空区间需有明确行为(建议:空区间返回空字符串,交由上层硬约束处理) + - **验收**: + - tokens=`[I, am, so, tired]`,`join(0,2)` 为 `"I am"` + +- [x] 3.4 实现 EN 关键词命中:`normalizeENKeyword` + `matchENKeyword` + - **规则**: + - `lowercase` + - strip 两端常见标点(使用配置 `punctuationStripSetEN`,全端一致) + - 等值比较(禁止 substring) + - **验收**: + - keyword=`but`:`but,` 命中;`rebuttal` 不命中 + +- [x] 3.5 实现 breaks 字典序比较 `compareBreaksLexicographically(a, b)` + - **规则**: + - 从 index=0 起逐项比较,首个不同元素更小者更小 + - 公共前缀相同则更短数组更小 + - **验收**: + - `[2] < [3]` + - `[2] < [2, 5]` + - `[2, 3] > [2]` + +--- + +## 4. 单元测试(Vitest,纯函数为主) + +- [x] 4.1 新建测试目录 `client/src/features/textWrap/core/__tests__/` + - **验收**:测试文件可被现有 test runner 发现。 + +- [x] 4.2 为 `normalizeWhitespace` 增加用例 + - **覆盖**:多空格、换行、首尾空白、空字符串、全空白字符串 + - **验收**:测试断言输出字符串与 `hadMultiWhitespace` 符合预期。 + +- [x] 4.3 为 `tokenizeEN` + `joinTokens` 增加用例 + - **覆盖**:普通句子、带标点的 token、单词间多个空格(先 normalize 再 tokenize) + - **验收**:tokens 序列与 join 后文本完全一致且确定。 + +- [x] 4.4 为 `matchENKeyword` 增加用例 + - **覆盖**:大小写、两端标点、误伤样例(rebuttal vs but) + - **验收**:命中与不命中行为符合 spec。 + +- [x] 4.5 为 breaks 字典序比较增加用例 + - **验收**:比较规则在多组数组上输出稳定顺序。 + +--- + +## 5. 最终自检清单(合入前) + +- [x] 5.1 `tsc --noEmit` 通过(或项目既有 TS 检查命令通过) + - **验收**:无类型错误。 + +- [x] 5.2 `vitest` 通过(或项目既有测试命令通过) + - **验收**:新增用例全部通过,不影响现有测试。 + +- [x] 5.3 代码注释口径检查(简体中文) + - **检查点**: + - EN 极简派与“断点只在词间” + - 全词等值匹配(禁止 substring) + - `start/end` 基于 normalizedText 的口径说明 + - **验收**:后续模块开发者只看代码也不会产生歧义。 + +--- + +## 6. 文档回写(任务清单执行完毕后必须做) + +- [x] 6.1 在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充执行状态 + - **建议写法**: + - 增加一行:`- **已完成编码(阶段性)**:core-contract(核心口径与契约)` + - **验收**:overview 能反映该子模块已完成,便于全局追踪。 + diff --git a/spec_kit/Text Wrap/modules/golden-tests/plan.md b/spec_kit/Text Wrap/modules/golden-tests/plan.md new file mode 100644 index 0000000..943d496 --- /dev/null +++ b/spec_kit/Text Wrap/modules/golden-tests/plan.md @@ -0,0 +1,57 @@ +# golden-tests(技术计划) + +## 1. 计划目标 + +建立可持续回归体系,覆盖: + +- **Golden Cases**:固定输入 → 固定输出(lines/wrappedText/meta),作为“可治理基线” +- **性质测试(property tests)**: + - 确定性:同输入多次调用输出一致 + - 近似单调性:availableWidth 变小不会让任一行变得更宽(同测量口径下) + - maxLines 不变差:maxLines 增加时至少不从可解变 overflow + +本模块产出测试数据与测试规则,不产出业务功能。 + +## 2. 约束与策略 + +### 2.1 测量可控 + +- APP:使用稳定的测量 mock(例如 `width = text.length`)保证 CI 可运行 +- WIDGET:使用固定 profile 或 `widthMode=APPROX`(单位为 tokenCount/graphemeCount) + +### 2.2 Golden 的组织方式 + +- 采用 TS fixture(便于类型校验与可读性) +- Golden case 至少包含: + - `text/lang/context/availableWidth/maxLines/overflowMode/lineMode/configVersion` + - 期望:`expected.lines/expected.wrappedText`(可选 meta 断言) + +### 2.3 覆盖面(首版) + +首版优先覆盖“易回归且高价值”的样例集: + +- EN/TC 各若干条:短/长、含标点/无标点、含 emoji、含 shift/accum/self、极窄宽度 + +> 说明:文档建议每种语言 20 条;首版先落最小可运行集合,后续迭代扩充但保持可解释与版本化。 + +## 3. 测试实现结构(客户端) + +- `client/src/features/textWrap/golden/fixtures.ts`(Golden cases) +- `client/src/features/textWrap/golden/__tests__/golden.test.ts` + - Golden 断言(lines/wrappedText) + - 性质测试(determinism/monotonic/maxLines) + +测试内暂时使用“测试版 wrapTextHarness”把已实现模块串起来: + +- normalize/tokenize(EN=tokenizeEN,TC=segmentGraphemes) +- generateBreakpoints +- search-engine-app / search-engine-widget + +待 integration 模块产出正式 `wrapText()` 后,再把测试入口切到正式函数(不改变期望数据)。 + +## 4. 完成定义(DoD) + +- Golden fixtures + 测试可在 CI 一键运行 +- 至少包含 EN/TC 的基础样例与 3 类性质测试 +- overview 更新记录变更文件 + diff --git a/spec_kit/Text Wrap/modules/golden-tests/spec.md b/spec_kit/Text Wrap/modules/golden-tests/spec.md new file mode 100644 index 0000000..68ad0e8 --- /dev/null +++ b/spec_kit/Text Wrap/modules/golden-tests/spec.md @@ -0,0 +1,57 @@ +# golden-tests(子模块规范) + +## 子模块名称 + +golden-tests(Golden Cases 与性质测试) + +## 目标描述 + +建立可持续的回归体系,保证换行算法满足: + +- 同输入同输出(确定性) +- 跨端一致(在固定测量/宽度 profile 下) +- 规则变更可控(通过 `configVersion` 回溯) + +本模块产出的是测试数据与测试规则,不产出业务功能。 + +## 输入/输出定义 + +### 输入 + +- Golden Case 集合(建议 JSON/TS fixture): + - `text` + - `lang` + - `context` + - `availableWidth` + - `maxLines` + - `fontSpec?`(APP) + - `constraints?` + - `overflowMode?` + - `configVersion` + +### 输出 + +- 对每个 case 的期望输出: + - `expected.lines: string[]` + - `expected.wrappedText: string` + - 可选:`expected.meta.breaks`、`expected.meta.fallback_type/overflow_type/reason` + +并定义性质测试(property tests): + +- **确定性**:同输入多次调用输出一致 +- **近似单调性**:`availableWidth` 变小不会让任一行变得更宽(在同测量模式下) +- **maxLines 不变差**:`maxLines` 增加时至少不从可解变 overflow + +## 验收标准(可验证) + +- **覆盖度**: + - 每种语言至少 20 个样例:短/长、含标点/无标点、含 emoji、含 protectedPhrases、含 shift/accum/self、极窄宽度 +- **固定口径**: + - 测量可控:APP 用稳定的测量 mock(或固定字体与平台);WIDGET 用固定 width profile +- **CI 可运行**:在自动化环境可一键跑完(不依赖人工操作) + +## 依赖与关联 + +- **依赖**:`core-contract`、`search-engine-*`、`scoring-tiebreak`、`overflow-fallback` +- **被依赖**:`integration`(接入后验收也可引用 Golden) + diff --git a/spec_kit/Text Wrap/modules/golden-tests/tasks.md b/spec_kit/Text Wrap/modules/golden-tests/tasks.md new file mode 100644 index 0000000..5a734fb --- /dev/null +++ b/spec_kit/Text Wrap/modules/golden-tests/tasks.md @@ -0,0 +1,50 @@ +# golden-tests(任务清单) + +> 目标:建立 Golden Cases 与性质测试(determinism/monotonic/maxLines)回归体系,保证算法演进可控。 + +## 0. 对齐与准备 + +- [x] 阅读并对齐口径 + - [x] 阅读 `spec_kit/Text Wrap/modules/golden-tests/spec.md` + - [x] 阅读 `spec_kit/Text Wrap/modules/golden-tests/plan.md` + - [x] 阅读 `设计说明文档/文档换行算法.md` 的 16.1/16.2 章节 +- [x] 创建客户端测试目录 + - [x] 新建 `client/src/features/textWrap/golden/` + +## 1. Golden fixtures + +- [x] 新建 `client/src/features/textWrap/golden/fixtures.ts` + - [x] 定义 `GoldenCase` 类型(text/lang/context/availableWidth/maxLines 等) + - [x] 补充 EN/TC 基础样例集合(首版最小可运行) + - [x] 对每个 case 写入 `expected.lines/expected.wrappedText` + +## 2. 测试 harness(临时串联) + +- [x] 在测试中实现 `wrapTextHarness()`(仅用于测试) + - [x] normalizeWhitespace + - [x] EN tokenizeEN / TC segmentGraphemes + - [x] 使用“全断点集合”(1..N-1,首版避免候选裁剪影响) + - [x] APP:searchBestLayoutApp(测量 mock=length) + - [x] WIDGET:searchBestLayoutWidget(widthMode=APPROX,单位一致) + +## 3. Golden cases 测试 + +- [x] 新建 `client/src/features/textWrap/golden/__tests__/golden.test.ts` + - [x] 遍历 fixtures,断言输出与 expected 完全一致 + +## 4. 性质测试(property tests) + +- [x] 确定性:同输入运行多次输出一致 +- [x] 近似单调性:availableWidth 变小不会让任一行变得更宽(同测量 mock 下,用 width=string.length) +- [x] maxLines 不变差:maxLines 增大时至少不从可解变 overflow(同测量 mock 下) + +## 5. 收尾 + +- [x] 跑测试与类型检查 + - [x] `npm test` + - [x] `npx tsc --noEmit` +- [x] 将本 `tasks.md` 全部勾选完成 +- [x] 更新 `spec_kit/overview.md` + - [x] 标记 `golden-tests` 已完成(阶段性) + - [x] 写入变更文件清单 + diff --git a/spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md b/spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md new file mode 100644 index 0000000..da0884c --- /dev/null +++ b/spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md @@ -0,0 +1,136 @@ +# grapheme-segmentation(技术计划) + +## 1. 计划目标 + +基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,在客户端侧(JS/TS)落地**可复用且确定性**的 TC grapheme cluster(字符簇)分割能力,用于 Text Wrap 的 TC tokens 生成,确保: + +- 不拆 surrogate pair、ZWJ、VS16、肤色修饰符、国旗(regional indicator flags)、组合字符(如 `é`) +- 同输入同输出(clusters 内容与顺序完全一致) +- 输出边界可作为 TC 断点边界(后续模块仅在 cluster 边界断行) +- 提供可解释 meta(采用何种策略、是否降级) + +## 2. 默认技术决策(本计划采用) + +> 说明:本模块要解决的是“分割口径”,不引入排版/搜索逻辑。 + +- **优先策略**:若运行环境支持 `Intl.Segmenter`,优先使用: + - `new Intl.Segmenter('zh-Hant', { granularity: 'grapheme' })` + - 取其 `segment(text)` 的 `segment` 字段作为 clusters +- **兜底策略(两种实现路径,默认选 A)**: + - **方案 A(推荐)**:引入轻量依赖 `grapheme-splitter` 作为 fallback,避免手写不完整的 Unicode 规则导致漏拆/误拆 + - **方案 B(无依赖兜底)**:实现“最低可用”分割(满足文档列出的组合不拆),但不承诺覆盖所有 Unicode 边界规则(风险较高) + +> 本计划默认采用 **方案 A**。若后续明确“禁止新增依赖”,再切换到方案 B 并补齐更多回归。 + +## 3. 目录与产物 + +本子模块目录: + +- `spec_kit/Text Wrap/modules/grapheme-segmentation/spec.md` +- `spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md`(本文) + +建议未来代码落位(实现阶段落地): + +- `client/src/features/textWrap/grapheme/` + - `segmentGraphemes.ts` + - `strategies/intlSegmenter.ts` + - `strategies/fallback.ts` + - `__tests__/segmentGraphemes.test.ts` + +## 4. API 设计(实现阶段的稳定契约) + +实现一个最小可用纯函数: + +- `segmentGraphemes(text: string, mode: 'PREFERRED' | 'FALLBACK') => { clusters: string[]; meta: { strategy: 'INTL_SEGMENTER' | 'FALLBACK'; hadFallback: boolean } }` + +设计约束: + +- `clusters.join('') === text`(不允许丢字符/改字符顺序) +- `clusters` 为空数组时必须是 `text==''`(不允许把空白当成 cluster 误输出) + +## 5. 实现步骤(按落地顺序) + +### 5.1 优先策略:Intl.Segmenter + +- 检测 `globalThis.Intl?.Segmenter` 是否可用 +- 若可用且 `mode='PREFERRED'`: + - 使用 `granularity='grapheme'` 分割 + - 输出 `meta.strategy='INTL_SEGMENTER'`,`hadFallback=false` + +注意: + +- 需要确认 Expo/RN 的运行时是否始终具备 `Intl.Segmenter`(不同 JS 引擎/版本可能差异) +- 即使可用,也必须通过回归样例验证“不拆”要求 + +### 5.2 兜底策略:Fallback + +当出现以下任一情况时进入 fallback: + +- `mode='FALLBACK'` +- `Intl.Segmenter` 不存在 +- `Intl.Segmenter` 运行抛错/返回异常结果(如空、丢字符) + +#### 方案 A:`grapheme-splitter` + +- 新增依赖:`grapheme-splitter` +- 使用其分割能力输出 clusters +- 输出 `meta.strategy='FALLBACK'`,`hadFallback=true` + +#### 方案 B:最低可用手写规则(仅在禁止依赖时启用) + +实现最低要求: + +- 合并 surrogate pair +- 合并 ZWJ sequence(`U+200D` 连接) +- 合并 variation selector(如 `U+FE0F`) +- 合并 skin tone modifier(`U+1F3FB..U+1F3FF`) +- 合并 regional indicator flags(两两成对) +- 合并组合字符(combining marks)与预组合等价形式(至少覆盖 `e\u0301`) + +风险提示: + +- 该实现容易漏掉其他扩展 grapheme cluster 规则;需要更高的测试覆盖与持续维护 + +## 6. 回归用例与测试计划(Vitest) + +### 6.1 必须覆盖的样例(文档要求) + +以下输入必须“不拆”为单个 cluster: + +- `👨‍👩‍👧‍👦` +- `🇸🇬` +- `👍🏽` +- `😮‍💨` +- `é`(至少覆盖 `e\u0301` 组合形式) + +断言: + +- `clusters.length === 1` +- `clusters[0] === input` + +### 6.2 基础性质测试(建议) + +- **可逆性**:`clusters.join('') === input` +- **确定性**:同输入多次调用输出完全一致 +- **空字符串**:`'' -> []` + +### 6.3 跨策略一致性(建议) + +在支持 `Intl.Segmenter` 的环境中: + +- 同一输入在 `PREFERRED` 与 `FALLBACK` 两种模式下输出 clusters 应一致 + - 若出现差异,必须新增回归样例并明确差异原因(并在上层通过 configVersion 治理) + +## 7. 性能与安全 + +- 单次分割复杂度应接近 \(O(n)\) +- 对超长文本(例如 > 2000 code units)建议在上层模块触发裁剪或打点(本模块仅保证不崩溃) +- 任何异常必须被捕获并降级到 fallback(保证“可用性优先”) + +## 8. 完成定义(DoD) + +- `segmentGraphemes()` 在本地可运行,并通过必测回归样例 +- 输出 meta 能区分 `INTL_SEGMENTER` 与 `FALLBACK` +- 单测覆盖:必测样例 + 可逆性 + 确定性 +- 文档口径与实现一致(不拆要求、fallback 触发条件、返回结构) + diff --git a/spec_kit/Text Wrap/modules/grapheme-segmentation/spec.md b/spec_kit/Text Wrap/modules/grapheme-segmentation/spec.md new file mode 100644 index 0000000..aa448dc --- /dev/null +++ b/spec_kit/Text Wrap/modules/grapheme-segmentation/spec.md @@ -0,0 +1,52 @@ +# grapheme-segmentation(子模块规范) + +## 子模块名称 + +grapheme-segmentation(TC 字符簇分割) + +## 目标描述 + +在 TC(中文/繁中)场景下,统一“按 grapheme cluster(字符簇)切分”的实现口径,确保: + +- 不拆分 surrogate pair(代理对) +- 不拆分 ZWJ 序列(家庭 emoji 等) +- 不拆分 variation selector(VS16 等) +- 不拆分 skin tone modifier(肤色修饰符) +- 不拆分 regional indicator flags(国旗) +- 覆盖组合字符(如 `é`) + +优先使用平台级 segmentation(如 ICU / 系统 API / `Intl.Segmenter`),无库时提供最低可用兜底。 + +## 输入/输出定义 + +### 输入 + +- `text: string`(已完成空白归一化的文本,或原始文本) +- `mode: 'PREFERRED' | 'FALLBACK'` + +### 输出 + +- `clusters: string[]` + - 每个元素为一个 grapheme cluster(用于 TC tokens) +- `meta?: { strategy: 'PLATFORM' | 'INTL_SEGMENTER' | 'FALLBACK'; hadFallback: boolean }` + +## 验收标准(可验证) + +至少通过以下回归样例(每个样例都必须“单 token 不拆”): + +- `👨‍👩‍👧‍👦` +- `🇸🇬` +- `👍🏽` +- `😮‍💨` +- `é` + +并满足: + +- **确定性**:同输入同输出(clusters 顺序与内容完全一致) +- **边界一致**:任意换行断点只允许发生在 `clusters` 边界 + +## 依赖与关联 + +- **被依赖**:`core-contract`(TC token 生成)、`breakpoint-candidates` +- **不依赖其他模块** + diff --git a/spec_kit/Text Wrap/modules/grapheme-segmentation/tasks.md b/spec_kit/Text Wrap/modules/grapheme-segmentation/tasks.md new file mode 100644 index 0000000..2b21b00 --- /dev/null +++ b/spec_kit/Text Wrap/modules/grapheme-segmentation/tasks.md @@ -0,0 +1,126 @@ +# grapheme-segmentation(任务清单) + +> 对应计划:`spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md` +> +> 状态含义:`[ ]` 未完成,`[x]` 已完成。 +> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。 + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未完成 + - `[x]` 已完成 +- 每个任务必须可独立验收(有明确产出与检查方式)。 +- 所有代码注释必须为简体中文,且把“分割口径”写清楚,避免跨端实现漂移。 + +--- + +## 1. 前置检查(环境能力与策略选择) + +- [x] 1.1 确认运行时是否支持 `Intl.Segmenter`(Expo/RN 当前引擎) + - **方式**:在本地运行/测试环境中打印或断言 `globalThis.Intl?.Segmenter` 是否存在 + - **验收**:记录结论:存在/不存在;若不存在,fallback 必须覆盖所有必测样例。 + +- [x] 1.2 确认 fallback 策略选择为“方案 A:`grapheme-splitter`” + - **要求**:若项目明确禁止新增依赖,需要在本任务中写明原因并切换到“方案 B(手写最低可用)”,同时补齐更高测试覆盖 + - **验收**:plan 与实际实现策略一致(不出现“文档写 A、代码做 B”的漂移)。 + +--- + +## 2. 依赖与目录骨架(客户端侧实现) + +- [x] 2.1 新建目录 `client/src/features/textWrap/grapheme/` + - **产出**(建议文件): + - `segmentGraphemes.ts`(对外纯函数) + - `strategies/intlSegmenter.ts` + - `strategies/fallback.ts` + - `types.ts`(返回结构与 meta 类型) + - `__tests__/segmentGraphemes.test.ts` + - **验收**:目录存在,TS 可正常 import(不报路径错误)。 + +- [x] 2.2(方案 A)新增依赖 `grapheme-splitter` 并锁定到 `client/package.json` + - **验收**: + - `npm install grapheme-splitter` 成功 + - `npm test` 仍能通过(不破坏现有测试) + +--- + +## 3. 纯函数实现(分割 + meta) + +- [x] 3.1 实现 `segmentGraphemes(text, mode)` 的返回契约 + - **要求**: + - 返回 `{ clusters, meta }` + - `clusters.join('') === text`(不丢字符/不改顺序) + - `text==''` 时 `clusters==[]` + - **验收**:为上述约束写入单测并通过。 + +- [x] 3.2 实现优先策略 `Intl.Segmenter`(`mode='PREFERRED'` 时优先) + - **要求**: + - `meta.strategy='INTL_SEGMENTER'` + - `meta.hadFallback=false` + - **验收**:在支持该能力的环境中,至少 1 个常规输入能走到该策略(可通过 meta 断言)。 + +- [x] 3.3 实现 fallback 策略(满足所有必测“不拆”样例) + - **触发条件**(任一满足即 fallback): + - `mode='FALLBACK'` + - `Intl.Segmenter` 不存在 + - `Intl.Segmenter` 抛错/返回异常结果(空/丢字符) + - **要求**: + - `meta.strategy='FALLBACK'` + - `meta.hadFallback=true` + - **验收**:必测样例全部通过(见 4.2)。 + +--- + +## 4. 单元测试(Vitest) + +- [x] 4.1 基础性质测试 + - **覆盖**: + - 可逆性:`clusters.join('') === input` + - 确定性:同输入多次调用输出一致 + - 空字符串:`'' -> []` + - **验收**:测试通过且不会出现偶现失败。 + +- [x] 4.2 必测回归样例(文档要求:每个都必须“不拆”为 1 个 cluster) + - **样例**: + - `👨‍👩‍👧‍👦` + - `🇸🇬` + - `👍🏽` + - `😮‍💨` + - `e\u0301`(组合字符形式) + - **断言**: + - `clusters.length === 1` + - `clusters[0] === input` + - **验收**:在本地 `npm test` 中稳定通过。 + +- [x] 4.3 跨策略一致性测试(在支持 `Intl.Segmenter` 的环境中执行) + - **内容**:同一输入在 `PREFERRED` 与强制 `FALLBACK` 下输出 clusters 一致 + - **验收**:一致;若不一致,必须新增回归样例并在文档中写明差异与治理方式(`configVersion`)。 + +--- + +## 5. 最终自检清单(合入前) + +- [x] 5.1 `npm test` 通过(包含本模块新增用例) + - **验收**:不影响现有测试文件。 + +- [x] 5.2 `npx tsc --noEmit` 通过(或项目既有 TS 检查命令通过) + - **验收**:无类型错误。 + +- [x] 5.3 注释与口径自检(简体中文) + - **检查点**: + - 明确“字符簇不拆”的边界意义(后续断点仅能在 clusters 边界) + - 明确 fallback 触发条件与 meta 含义 + - **验收**:后续模块开发者只看代码也不会产生歧义。 + +--- + +## 6. 文档回写(任务清单执行完毕后必须做) + +- [x] 6.1 在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充执行状态 + - **建议写法**: + - 增加一行:`- **已完成编码(阶段性)**:grapheme-segmentation(TC 字符簇分割)` + - **验收**:overview 能反映该子模块已完成,便于全局追踪。 + diff --git a/spec_kit/Text Wrap/modules/integration/plan.md b/spec_kit/Text Wrap/modules/integration/plan.md new file mode 100644 index 0000000..d125a54 --- /dev/null +++ b/spec_kit/Text Wrap/modules/integration/plan.md @@ -0,0 +1,77 @@ +# integration(技术计划) + +## 1. 计划目标 + +实现对外统一入口 `wrapText()`(纯函数风格),把已完成的子模块串成可复用算法模块,供 Home(APP)与 Widget(WIDGET)调用: + +- 参数口径统一:`lang/context/availableWidth/maxLines/overflowMode/lineMode/configVersion/debug` +- 输出统一:`lines[]/wrappedText/meta`,且 meta 可用于治理与 UI 兜底 +- 当 `meta.fallback_type=SYSTEM_DEFAULT` 时,UI 可选择交给系统排版(算法层仅标记,不擅自改变 UI 行为) + +## 2. 入口签名(与大 spec 对齐) + +实现并导出: + +```ts +wrapText({ + text, + lang, + availableWidth, + maxLines, + context, + fontSpec?, + overflowMode?, + lineMode?, + constraints?, + configVersion?, + debug? +}) => { lines, wrappedText, meta } +``` + +## 3. 组装流程(按执行顺序) + +1. **normalizeWhitespace**(core-contract) +2. **tokenize** + - EN:tokenizeEN + - TC:segmentGraphemes → Token[] +3. **breakpoint-candidates** + - generateBreakpoints(传入 constraints.forbiddenBreakRanges) +4. **search** + - APP:searchBestLayoutApp(DP+TopK,测量优先) + - WIDGET:searchBestLayoutWidget(Beam,默认 APPROX;可选 MEASURE) +5. **overflow-fallback** + - 当搜索失败或未覆盖到 N:按 overflowMode 执行(ELLIPSIS/CLIP/SYSTEM_DEFAULT) +6. **meta 汇总** + - breaks(若可得) + - scoreTopTerms(debug=true 时) + - fallback_type/overflow_type/reason + - configVersion + +## 4. 默认值与映射(固定,确定性) + +- `overflowMode`: + - APP 默认 `CLIP` + - WIDGET 默认 `ELLIPSIS` +- `lineMode`:默认 `AUTO` +- `configVersion`:默认 `'v1'`(若调用方未传) +- WIDGET widthMode:默认 `APPROX` + +## 5. 代码落位(客户端) + +- `client/src/features/textWrap/` + - `wrapText.ts`(入口实现) + - `types.ts`(对外输入/输出类型) + - `index.ts`(统一导出) + +## 6. 测试与回归 + +- 将 `golden-tests` 的测试入口从测试 harness 切换为正式 `wrapText()`(期望不变) +- 增加 1 个 integration 单测:验证 `SYSTEM_DEFAULT` meta 语义不变 + +## 7. 完成定义(DoD) + +- `wrapText()` 可在 APP/WIDGET 两种 context 下运行 +- 输出 meta 字段可用于 UI 治理与回溯(含 configVersion) +- 全量 `npm test` 与 `tsc --noEmit` 通过 +- overview 更新记录变更文件 + diff --git a/spec_kit/Text Wrap/modules/integration/spec.md b/spec_kit/Text Wrap/modules/integration/spec.md new file mode 100644 index 0000000..4668155 --- /dev/null +++ b/spec_kit/Text Wrap/modules/integration/spec.md @@ -0,0 +1,52 @@ +# integration(子模块规范) + +## 子模块名称 + +integration(Home / Widget 接入) + +## 目标描述 + +把 `wrapText()` 模块以一致参数口径接入到两端渲染链路中,并定义 UI 侧对 meta 的处理规则,确保: + +- Home 与 Widget 使用同一套配置/词库/版本号(`configVersion`) +- 同场景下输出一致;不同测量能力导致差异时“可解释且可治理” +- SYSTEM_DEFAULT 的语义不被 UI 误用 + +## 输入/输出定义 + +### 输入 + +- Home(APP)侧输入: + - `text/lang/availableWidth/maxLines/fontSpec/context='APP'` + - 可选:`constraints/overflowMode/lineMode/configVersion/debug` +- Widget(WIDGET)侧输入: + - `text/lang/availableWidth(profile)/maxLines/context='WIDGET'` + - 可选:`constraints/overflowMode/configVersion/debug` + +### 输出 + +- UI 渲染消费: + - `lines[]`:逐行渲染或插入 `\n` + - `wrappedText`:用于一次性渲染或日志/缓存 + - `meta`:用于调试、打点、兜底策略选择 + +## 验收标准(可验证) + +- **参数映射一致**: + - 两端对 `lang/context/availableWidth/maxLines/overflowMode/lineMode` 的默认值与映射一致 + - `configVersion` 必须随结果一起回传到 meta 或打点 +- **UI 兜底语义正确**: + - 当 `meta.fallback_type=SYSTEM_DEFAULT` 时: + - UI 允许选择“交给系统排版”(例如不插入 `\n` 或忽略 `lines[]`) + - 但不改变算法层返回值 +- **缓存与复用**: + - Home 可对同文案同参数缓存 `wrappedText/lines/breaks` + - Widget 可对固定 profile 文案缓存结果(避免频繁计算) +- **回归可对齐**: + - Golden Cases 在 Home 与 Widget(固定 profile)下可跑通并对齐预期 + +## 依赖与关联 + +- **依赖**:全部核心模块(`core-contract`、`breakpoint-candidates`、`search-engine-*`、`scoring-tiebreak`、`overflow-fallback`、`golden-tests`) +- **被依赖**:无(最终落地层) + diff --git a/spec_kit/Text Wrap/modules/integration/tasks.md b/spec_kit/Text Wrap/modules/integration/tasks.md new file mode 100644 index 0000000..c569a48 --- /dev/null +++ b/spec_kit/Text Wrap/modules/integration/tasks.md @@ -0,0 +1,63 @@ +# integration(任务清单) + +> 目标:实现 `wrapText()` 统一入口并串联所有子模块,供 Home/Widget 使用。 + +## 0. 对齐与准备 + +- [x] 阅读并对齐口径 + - [x] 阅读 `spec_kit/Text Wrap/modules/integration/spec.md` + - [x] 阅读 `spec_kit/Text Wrap/modules/integration/plan.md` + - [x] 阅读 `spec_kit/Text Wrap/spec.md` 的对外接口定义 + +## 1. 对外类型定义 + +- [x] 新建 `client/src/features/textWrap/types.ts` + - [x] 定义 `WrapTextInput`(与大 spec 对齐) + - [x] 定义 `WrapTextMeta`(包含 configVersion/fallback_type/overflow_type/reason/breaks/scoreTopTerms) + - [x] 定义 `WrapTextOutput` + +## 2. 实现 wrapText() 入口 + +- [x] 新建 `client/src/features/textWrap/wrapText.ts` + - [x] normalizeWhitespace(固定策略 NORMALIZE) + - [x] tokenize: + - [x] EN:tokenizeEN + - [x] TC:segmentGraphemes → Token[] + - [x] generateBreakpoints(接入 forbiddenBreakRanges) + - [x] 组装 scoring 的 lexicons: + - [x] protectedPhrases 来自 constraints.protectedPhrases(若有) + - [x] 搜索: + - [x] APP:searchBestLayoutApp(测量 mockable;fontSpec 缺字段报错由 width-measurement 保证) + - [x] WIDGET:searchBestLayoutWidget(默认 APPROX;可选 MEASURE) + - [x] 搜索失败/未覆盖 N: + - [x] 调用 applyOverflowFallback(overflowMode 默认:APP=CLIP,WIDGET=ELLIPSIS) + - [x] 汇总输出 meta: + - [x] configVersion + - [x] breaks(若可得) + - [x] scoreTopTerms(debug=true) + - [x] fallback_type/overflow_type/reason + +## 3. 导出 + +- [x] 新建 `client/src/features/textWrap/index.ts` + - [x] 导出 types 与 `wrapText` + +## 4. 调整 Golden Tests 入口 + +- [x] 将 `client/src/features/textWrap/golden/__tests__/golden.test.ts` 从测试 harness 切换为 `wrapText()` + - [x] 保持现有 fixtures 期望不变 + +## 5. 单测补充(最小) + +- [x] 新建 `client/src/features/textWrap/__tests__/wrapText.integration.test.ts` + - [x] SYSTEM_DEFAULT:meta.fallback_type=SYSTEM_DEFAULT 且仍返回 lines/wrappedText + +## 6. 收尾 + +- [x] `npm test` +- [x] `npx tsc --noEmit` +- [x] 将本 `tasks.md` 全部勾选完成 +- [x] 更新 `spec_kit/overview.md` + - [x] 标记 `integration` 已完成(阶段性) + - [x] 写入变更文件清单 + diff --git a/spec_kit/Text Wrap/modules/overflow-fallback/plan.md b/spec_kit/Text Wrap/modules/overflow-fallback/plan.md new file mode 100644 index 0000000..fbfafb8 --- /dev/null +++ b/spec_kit/Text Wrap/modules/overflow-fallback/plan.md @@ -0,0 +1,109 @@ +# overflow-fallback(技术计划) + +## 1. 计划目标 + +实现统一的“溢出与兜底”策略模块,用于在以下情况给出确定性且可解释的返回: + +- 搜索器无法在 `<=maxLines` 覆盖到结束边界 `N` +- `lineMode=FIXED` 需要刚好 `maxLines` 但无解 +- 测量不可用或失败导致无法执行宽度派约束(尤其 Widget) + +支持三种模式: + +- `ELLIPSIS`:最后一行加省略号并保证不超宽(必要时回退移除 token 再加) +- `CLIP`:截断到 `maxLines`(不加省略号) +- `SYSTEM_DEFAULT`:算法层不插入换行,交给系统排版(仅在 meta 标记) + +## 2. 对外输入/输出(与 spec 对齐) + +### 2.1 输入 + +- `tokens: Token[]` +- `partialLayout?: { breaks: number[]; lines: Array<{ start; end; text? }> }` + - 若提供:表示搜索器的 best-effort(可能未覆盖到 N) +- `overflowMode: 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'` +- `availableWidth: number` +- `maxLines: number` +- `lang: 'TC' | 'EN'` +- `context: 'APP' | 'WIDGET'` +- `ellipsisToken: string`(推荐 `"…"`,配置化) +- `measure?: { contextProfile; fontSpec; measureWidthImpl; widgetEnableMeasure? }`(用于“加省略号后再测量”) +- `reason: 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'TOO_LONG' | 'WIDOW' | 'PARTICLE' | string` + +### 2.2 输出 + +统一返回: + +- `result: { lines: string[]; wrappedText: string; meta: { fallback_type; overflow_type; reason } }` + +其中: + +- `fallback_type`: `NONE | RELAX_RULES | SYSTEM_DEFAULT` +- `overflow_type`: `NONE | ELLIPSIS | CLIP` + +## 3. 核心行为细则(对应文档 12.x) + +### 3.1 overflow 判定 + +在本模块内不重新跑搜索,仅基于输入判断: + +- 若 `partialLayout` 覆盖到 `N`(即最后一行 `end==N`),则 overflow_type=NONE +- 否则为 overflow,按 overflowMode 执行 + +### 3.2 SYSTEM_DEFAULT(12.4A) + +- 返回 `lines=[原文单行]`,`wrappedText=原文` +- `meta.fallback_type='SYSTEM_DEFAULT'` +- `meta.overflow_type='NONE'`(因为不再输出算法换行;由 UI 决定是否完全交给系统) +- `meta.reason=输入 reason` + +### 3.3 CLIP + +- 若 `partialLayout` 有 lines:取前 `maxLines` 行,重组 `wrappedText=lines.join('\n')` +- 若无:返回单行原文 +- `meta.overflow_type='CLIP'`,`fallback_type='NONE'`,`reason=输入 reason` + +### 3.4 ELLIPSIS(12.3/12.5) + +仅处理最后一行: + +1. 选取 baseLines: + - 优先使用 `partialLayout.lines`(若为空则把全文当单行) + - 截断到 `maxLines`(只在最后一行做 ellipsis) +2. 清理规则(固定,确定性): + - 不输出 `" …"`:最后一行末尾空白先 trim + - 不输出 `",…"/"。…"`:若最后一行末尾是常见 TC 标点(`,。!?;:、`),则移除该标点再加 ellipsis +3. 宽度检查(12.3): + - 若提供测量能力且启用: + - 重新测量 `lastLine+ellipsisToken` + - 若超宽:按语言回退单位移除 token 再加省略号并重测 + - EN:按整词(token) + - TC:按 grapheme(token) + - 若测量不可用:不做重测,直接输出(meta.reason 仍保留) + +> 说明:首版不做“避免截断 emotionPhrase 的整段前移/整段省略”,该策略可在后续结合 scoring 决策升级,但必须保持确定性。 + +## 4. 代码落位(客户端) + +- `client/src/features/textWrap/overflow/` + - `types.ts` + - `ellipsis.ts` + - `fallback.ts`(入口:applyOverflowFallback) + - `index.ts` + - `__tests__/overflowFallback.test.ts` + +## 5. 测试计划(Vitest) + +- SYSTEM_DEFAULT:lines 单行,meta.fallback_type=SYSTEM_DEFAULT +- CLIP:超过 maxLines 时截断行数 +- ELLIPSIS: + - 末尾空白去除,不输出 `" …"` + - 末尾 TC 标点去除,不输出 `",…"` + - 测量超宽时按 token 回退直到不超宽(用 mock measureWidthImpl) + +## 6. 完成定义(DoD) + +- 三种 overflowMode 行为稳定且可解释 +- ELLIPSIS 的清理与回退测量实现完毕(可测) +- 输出 meta 字段满足文档 12/14 的治理需求 + diff --git a/spec_kit/Text Wrap/modules/overflow-fallback/spec.md b/spec_kit/Text Wrap/modules/overflow-fallback/spec.md new file mode 100644 index 0000000..855a4a8 --- /dev/null +++ b/spec_kit/Text Wrap/modules/overflow-fallback/spec.md @@ -0,0 +1,52 @@ +# overflow-fallback(子模块规范) + +## 子模块名称 + +overflow-fallback(溢出与兜底) + +## 目标描述 + +统一定义“无解/溢出”时的行为与返回语义,确保: + +- APP 与 WIDGET 在无法覆盖全文时表现一致且可解释 +- ELLIPSIS 的字符、清理规则与再次测量约束统一 +- SYSTEM_DEFAULT 的返回语义明确(算法层不擅自改变 UI 行为) + +## 输入/输出定义 + +### 输入 + +- `tokens: Token[]` +- `partialBest?: Layout`(搜索器找到的最佳 partial 或 best effort) +- `overflowMode: 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'` +- `availableWidth: number` +- `maxLines: number` +- `lang: 'TC' | 'EN'` +- `context: 'APP' | 'WIDGET'` +- `ellipsisToken: string`(推荐 `"…"`,必须配置化) +- `measureWidth?: fn`(用于“加省略号后再测量”) + +### 输出 + +- `result: { lines: string[]; wrappedText: string; meta: { fallback_type: 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT'; overflow_type: 'NONE' | 'ELLIPSIS' | 'CLIP'; reason: string } }` + +## 验收标准(可验证) + +- **overflow 判定一致**: + - 搜索无法在 `<=maxLines` 覆盖到结束边界 N,即为 overflow +- **ELLIPSIS 规则一致**: + - 仅处理最后一行 + - 加省略号后必须再次检查不超宽(必要时回退移除 token 再加省略号) + - EN 回退单位为整词;TC 回退单位为 grapheme + - 清理规则:不输出 `" …"`;不输出 `",…"/"。…"`(按配置决定是否移除末尾标点再加省略号,但必须固定) +- **SYSTEM_DEFAULT 语义一致**: + - 仍返回 `lines/wrappedText` + - 仅在 meta 标记 `fallback_type=SYSTEM_DEFAULT` + - UI 可依据 meta 决定是否完全交给系统排版 +- **确定性**:同输入同输出(含 meta) + +## 依赖与关联 + +- **依赖**:`core-contract`(重组)、`width-measurement`(测量/降级)、`scoring-tiebreak`(避免截断短语的策略可复用评分) +- **被依赖**:`search-engine-app`、`search-engine-widget`、`integration`、`golden-tests` + diff --git a/spec_kit/Text Wrap/modules/overflow-fallback/tasks.md b/spec_kit/Text Wrap/modules/overflow-fallback/tasks.md new file mode 100644 index 0000000..217bef7 --- /dev/null +++ b/spec_kit/Text Wrap/modules/overflow-fallback/tasks.md @@ -0,0 +1,65 @@ +# overflow-fallback(任务清单) + +> 目标:统一实现 `ELLIPSIS/CLIP/SYSTEM_DEFAULT` 的溢出与兜底语义(文档 12.x),输出稳定可解释的 meta。 + +## 0. 对齐与准备 + +- [x] 阅读并对齐口径 + - [x] 阅读 `spec_kit/Text Wrap/modules/overflow-fallback/spec.md` + - [x] 阅读 `spec_kit/Text Wrap/modules/overflow-fallback/plan.md` + - [x] 阅读 `设计说明文档/文档换行算法.md` 的 12.x/14 章节 +- [x] 创建客户端模块目录 + - [x] 新建 `client/src/features/textWrap/overflow/` + +## 1. 类型与对外接口 + +- [x] 新建 `client/src/features/textWrap/overflow/types.ts` + - [x] 定义 `OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'` + - [x] 定义 `FallbackType = 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT'` + - [x] 定义 `OverflowType = 'NONE' | 'ELLIPSIS' | 'CLIP'` + - [x] 定义 `OverflowReason`(至少包含:NO_CANDIDATE/WIDTH_UNKNOWN/TOO_LONG/WIDOW/PARTICLE) + - [x] 定义 `PartialLayoutInput`:`breaks` + `lines[{start,end,text?}]` + - [x] 定义 `ApplyOverflowFallbackInput/Result` +- [x] 新建 `client/src/features/textWrap/overflow/index.ts` + - [x] 统一导出 types 与入口 `applyOverflowFallback()` + +## 2. ELLIPSIS 核心逻辑 + +- [x] 新建 `client/src/features/textWrap/overflow/ellipsis.ts` + - [x] 实现 `cleanLineBeforeEllipsis(line, lang)`: + - [x] trim 末尾空白,避免 `" …"` + - [x] TC:若末尾是 `,。!?;:、`,移除该标点,避免 `",…"` + - [x] 实现 `applyEllipsisToLastLine(...)`: + - [x] 仅处理最后一行 + - [x] EN 回退单位=整词;TC 回退单位=grapheme + - [x] 若提供测量能力:加省略号后必须重新测量,超宽则循环回退再测量 + - [x] 若测量不可用:直接输出(确定性) + +## 3. 入口:applyOverflowFallback + +- [x] 新建 `client/src/features/textWrap/overflow/fallback.ts` + - [x] 实现 `applyOverflowFallback(input)`: + - [x] overflow 判定:partialLayout 是否覆盖到 N + - [x] `SYSTEM_DEFAULT`(12.4A):返回单行原文 + meta.fallback_type=SYSTEM_DEFAULT + - [x] `CLIP`:截断到 maxLines + - [x] `ELLIPSIS`:调用 ellipsis 逻辑 + - [x] meta 输出:`fallback_type/overflow_type/reason` + +## 4. 单测(Vitest) + +- [x] 新建 `client/src/features/textWrap/overflow/__tests__/overflowFallback.test.ts` + - [x] SYSTEM_DEFAULT 语义 + - [x] CLIP 截断行为 + - [x] ELLIPSIS 清理规则(不输出 `" …"`、不输出 `",…"`) + - [x] ELLIPSIS 测量回退:mock measureWidthImpl,让超宽时按 token 回退直到不超宽 + +## 5. 收尾 + +- [x] 跑测试与类型检查 + - [x] `npm test` + - [x] `npx tsc --noEmit` +- [x] 将本 `tasks.md` 全部勾选完成 +- [x] 更新 `spec_kit/overview.md` + - [x] 标记 `overflow-fallback` 已完成编码(阶段性) + - [x] 写入变更文件清单 + diff --git a/spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md b/spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md new file mode 100644 index 0000000..9dbb47a --- /dev/null +++ b/spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md @@ -0,0 +1,179 @@ +# scoring-tiebreak(技术计划) + +## 1. 计划目标 + +基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,在客户端实现可解释的评分模型与确定性 tie-break,保证: + +- **整数评分**:所有评分项使用整数,避免浮点误差 +- **权重写死客户端**:首版权重/词表内置在客户端代码(后续如需灰度,用 `configVersion` 管理) +- **短语匹配口径固定**:emotionPhrases/protectedPhrases 必须是“连续 token 完全匹配” +- **更长短语优先**:采用方案 A——在触发拆分惩罚时按“被拆分短语长度”追加惩罚(确定性) +- **debug 可解释**:输出 score breakdown;debug=true 时输出 Top-3,按规则优先级排序(不是按 |delta|) +- **tieKey 固定**:严格按文档第 11 节顺序构造 tieKey;Widget approx mode 仍使用数值 width(可为近似单位),确保 tie-break 可用 + +本模块不负责搜索(DP/Beam),只负责对“候选 layout”打分并给出可比较的 tieKey。 + +## 2. 默认技术决策(本计划采用) + +### 2.1 评分数值体系 + +- 全部评分项使用 **整数** +- 不引入 EPS(因为不使用浮点);分数相等即为相等 + +### 2.2 权重与默认值(写死客户端) + +首版采用文档 10.3 的建议量级(可在实现中集中定义为常量/配置对象): + +- `P_EMOTION_SPLIT = 10000` +- `P_PROTECTED_SPLIT = 10000` +- `P_WIDOW_WORD = 800` +- `P_WIDOW_LINE = 500` +- `P_SHORT_LASTLINE = 300` +- `P_PARTICLE_ISO = 200` +- `R_PUNCT_BREAK = 80` +- `R_SHIFT_BREAK = 60` +- `R_ACCUM_BREAK = 40` +- `R_SELF_BREAK = 20` +- `P_OVER_MAXLEN = 30` +- `P_TOO_SHORT = 10` + +> 说明:权重必须集中在一个文件,禁止散落在各函数内;后续调整通过 `configVersion` 记录。 + +### 2.3 最小词表(TC/EN) + +首版提供最小可用词表(来源:文档第 7 节): + +- TC: + - shift:`但/可是/然而/却/只是/偏偏` + - accum:`已经/一直/曾经/终于/还是/到现在` + - self:`你/我/自己/我们/别人` + - emotionWords(可选):`累/痛/怕/孤单/委屈/撑/崩溃/放弃` +- EN(全词等值匹配,使用 core-contract 的 normalize 规则): + - shift:`but/yet/so`(and 轻量可选) + - accum:`already/still/even/just/really` + - self:`you/yourself/me/we` + - emotionWords(可选):`tired/afraid/lonely/hurt/overwhelmed/give up` + +emotionPhrasesTC/EN 与 protectedPhrases: + +- 首版允许为空数组(先把匹配与惩罚机制写死) +- 若业务侧已有短语清单,后续直接填充并通过 Golden Cases 回归 + +### 2.4 宽度派(tieKey 使用 width) + +- layoutCandidate.lines[*].width 字段作为 tieKey 的 width 来源 +- 在 width unknown/approx mode 时,上游仍需给出“数值宽度”(例如用 wordCount/graphemeCount 作为 width 近似值),保证 tie-break 可执行 + +## 3. 目录与产物(客户端侧实现) + +建议代码落位: + +- `client/src/features/textWrap/scoring/` + - `types.ts`(Layout/LineSegment/ScoreBreakdown) + - `weights.ts`(权重常量,写死) + - `lexicons.ts`(最小词表,写死) + - `phraseMatch.ts`(连续 token 完全匹配 + span 预计算) + - `score.ts`(按 10.1 顺序累计分数) + - `tieKey.ts`(按 11 节构造 tieKey) + - `debug.ts`(Top-3 提取与排序) + - `index.ts` + - `__tests__/scoringTiebreak.test.ts` + +## 4. 输入/输出契约(实现阶段写死) + +### 4.1 输入 + +- `layoutCandidate`: + - `breaks: number[]` + - `lines: Array<{ start; end; text; width; tokenCount; charCount }>` +- `lang: 'TC' | 'EN'` +- `context: 'APP' | 'WIDGET'` +- `config`: + - `weights: Record`(首版由内置默认值生成) + - `idealWidthRatio: { APP: 0.90; WIDGET: 0.95 }`(来自文档 8.3A) + - `ellipsisToken: "…"`(用于溢出模块;本模块只保留配置入口) + - `particleWhitelistTC: string[]`(语尾语助词白名单;惩罚减半) +- `lexicons`(首版使用内置最小词表;允许调用方覆盖) + +### 4.2 输出 + +- `scoredLayout`: + - `score: number` + - `flags: { emotionSplit?: boolean; overflowed?: boolean; fallback?: boolean }` + - `tieKey: Array` + - `scoreBreakdown?: { total: number; terms: Array<{ key: string; delta: number; detail?: any }> }` + +## 5. 实现步骤(按落地顺序) + +### 5.1 Phrase 匹配与 span 预计算(连续 token 完全匹配) + +实现口径(必须): + +- EN:对输入 tokens 使用 `core-contract` 的 `normalizeWhitespace + tokenizeEN` +- TC:对输入 tokens 使用 `grapheme-segmentation` 的 clusters +- 对 phrase: + - EN:先 normalizeWhitespace,再按空格切分为词序列(不做 substring) + - TC:按 grapheme clusters 切分 +- 匹配方式:在 token 序列中寻找 **连续区间** 完全匹配 +- 输出 spans:`{ start, end, length }`(end 为半开区间) + +### 5.2 评分项(严格按 10.1 顺序累计) + +按文档 10.1 固定顺序计算并记录 breakdown: + +1. **情绪短语/受保护短语拆分惩罚**(最高优先) + - 若某 phrase span 被 breaks 拆到不同的行: + - `score -= P_*` + - 并按方案 A 追加惩罚:`score -= phraseLength`(或 `k * phraseLength`,k 写死为 1,确保确定性) + - flags:`emotionSplit=true`(或 protectedSplit 也可复用同一 flag/额外字段,需写死) +2. **行长度(ideal/over/too short)** + - `idealWidth = availableWidth * idealWidthRatio[context]`(availableWidth 由上游传入) + - 以 width-based 作为主要项:`score += -abs(line.width - idealWidth)` + - overMaxLen/tooShort 按配置阈值与权重惩罚(首版可先按文档默认区间落地) +3. **Widow / 单字行惩罚** +4. **TC 标点断点奖励** +5. **Shift/Accum/Self 断点奖励(含 EN 行首/行尾感知)** +6. **尾行过短惩罚(SHORT_LASTLINE)** + +> 注意:必须保留每一项的确定性记录,不允许“覆盖”前序裁决结果(见文档 10.1A)。 + +### 5.3 Debug Top-3 输出 + +- 输出结构:`ScoreBreakdown = { total, terms[] }` +- debug=true 时只保留 Top-3 term,但排序规则必须按优先级(10.1 顺序),同优先级再按出现顺序稳定排序 + +### 5.4 tieKey 构造(按 11 节固定顺序) + +按文档顺序生成 tieKey(比较时逐项比较): + +1. `emotionSplit=false` 优先(可用 `0/1`) +2. `overflowed=false` 优先 +3. `lastLineWidth` 更大优先(因此 tieKey 可存 `-lastLineWidth` 或比较时反向) +4. 行宽分布更均匀优先(`maxWidth-minWidth` 更小优先) +5. 断点更接近理想切分点优先(距离和更小) +6. breaks 字典序更靠前优先(使用 core-contract 的 breaks 比较规则) + +## 6. 测试计划(Vitest) + +### 6.1 Phrase 匹配 + +- EN:确保全词等值匹配(`but,` 命中 `but`;`rebuttal` 不命中) +- TC:emoji/组合字符的 phrase 不被拆(依赖 grapheme-segmentation 已覆盖) + +### 6.2 评分顺序与 breakdown + +- 构造触发 EmotionSplit 的 layout,断言 breakdown 中第一项为 EMOTION_SPLIT(或对应 key),且其记录不会被后续项覆盖 + +### 6.3 tieKey 确定性 + +- 构造同分 layout,验证 tieKey 顺序能稳定选出同一个最优 + +## 7. 完成定义(DoD) + +- 整数评分全链路跑通(无浮点) +- phrase 连续 token 完全匹配实现完毕(EN/TC) +- scoring 按 10.1 顺序累计,并输出可解释 breakdown +- debug Top-3 按优先级输出 +- tieKey 按 11 节固定顺序构造与比较 +- 单测覆盖匹配、评分顺序、tieKey 稳定性 + diff --git a/spec_kit/Text Wrap/modules/scoring-tiebreak/spec.md b/spec_kit/Text Wrap/modules/scoring-tiebreak/spec.md new file mode 100644 index 0000000..7664aa1 --- /dev/null +++ b/spec_kit/Text Wrap/modules/scoring-tiebreak/spec.md @@ -0,0 +1,54 @@ +# scoring-tiebreak(子模块规范) + +## 子模块名称 + +scoring-tiebreak(评分模型与确定性裁决) + +## 目标描述 + +定义并实现可解释的评分模型(Scoring Model)与固定 tie-break 规则,用于在“合法断点组合”中选出最优布局(layout),并保证跨端完全一致。 + +本模块不负责搜索(DP/Beam),但负责: + +- 每行/每断点的评分项计算顺序(必须固定) +- 情绪短语/受保护短语的匹配口径(连续 token 完全匹配) +- Debug breakdown 的结构与 Top-3 输出排序(按优先级而非 |delta|) +- tieKey 的构造与比较规则(含 breaks 字典序定义) + +## 输入/输出定义 + +### 输入 + +- `layoutCandidate: { breaks: number[]; lines: Array<{ start: number; end: number; text: string; width: number; tokenCount: number; charCount: number }> }` +- `lang: 'TC' | 'EN'` +- `context: 'APP' | 'WIDGET'` +- `config: { weights: Record; idealWidthRatio: { APP: number; WIDGET: number }; ellipsisToken: string; particleWhitelistTC: string[]; eps?: number }` +- `lexicons: { emotionPhrasesTC: string[]; emotionPhrasesEN: string[]; protectedPhrases?: string[]; shiftWordsTC: string[]; shiftWordsEN: string[]; accumWordsTC: string[]; accumWordsEN: string[]; selfWordsTC: string[]; selfWordsEN: string[]; emotionWordsTC?: string[]; emotionWordsEN?: string[] }` + +### 输出 + +- `scoredLayout: { score: number; flags: { emotionSplit?: boolean; overflowed?: boolean; fallback?: boolean }; tieKey: Array; scoreBreakdown?: { total: number; terms: Array<{ key: string; delta: number; detail?: any }> } }` + +## 验收标准(可验证) + +- **评分项顺序固定**:评分必须严格按算法文档 10.1 的顺序计算并记录(不可重排) +- **短语匹配口径正确**: + - emotionPhrase/protectedPhrases:连续 token 完全匹配(不允许跳 token、不允许模糊) + - EN 关键词命中遵循 `core-contract` 的“全词等值匹配” +- **冲突裁决一致**: + - emotionPhrases 与 protectedPhrases 同级强保护 + - 同等条件下更长短语优先(需在惩罚或 tieKey 中体现,且确定性) +- **奖励折减规则一致**: + - EmotionWord 与 Accum 同时命中时:Accum 奖励按 0.5 倍(先算 EmotionWord 再折减) +- **tie-break 一致**: + - 规则顺序固定(emotionSplit/overflowed/lastLineWidth/均衡度/理想距离/breaks 字典序) + - breaks 字典序定义:逐项比较;公共前缀相同则更短数组更小 +- **debug Top-3 输出排序一致**: + - debug 模式关键项按优先级输出(非 |delta|) +- **整数分优先**:尽量使用整数评分;若使用浮点必须定义 EPS,并用 `abs(a-b)<=EPS` 判断近似相等 + +## 依赖与关联 + +- **依赖**:`core-contract`(索引、文本重组、EN 命中工具) +- **被依赖**:`search-engine-app`、`search-engine-widget`、`overflow-fallback`、`golden-tests` + diff --git a/spec_kit/Text Wrap/modules/scoring-tiebreak/tasks.md b/spec_kit/Text Wrap/modules/scoring-tiebreak/tasks.md new file mode 100644 index 0000000..76a94d8 --- /dev/null +++ b/spec_kit/Text Wrap/modules/scoring-tiebreak/tasks.md @@ -0,0 +1,122 @@ +# scoring-tiebreak(任务清单) + +> 目标:实现“评分模型 + 确定性 tie-break”,用于对候选 layout 打分并产出可比较的 `tieKey`。 +> 约束:**整数评分**、**权重/最小词表写死客户端**、短语匹配为**连续 token 完全匹配**、debug Top-3 按**优先级顺序**输出。 + +## 0. 准备与对齐 + +- [x] 阅读并对齐口径 + - [x] 复核 `spec_kit/Text Wrap/modules/scoring-tiebreak/spec.md` + - [x] 复核 `spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md` + - [x] 复核 `设计说明文档/文档换行算法.md` 中 10.x(评分)与 11(tie-break)章节的条目顺序 +- [x] 在客户端创建模块目录 + - [x] 新建 `client/src/features/textWrap/scoring/` + - [x] 确认后续文件均落在该目录下,避免与 `core/`、`breakpoints/`、`measure/` 混放 + +## 1. 定义类型与对外接口 + +- [x] 新建 `client/src/features/textWrap/scoring/types.ts` + - [x] 定义 `TextWrapContext = 'APP' | 'WIDGET'` + - [x] 定义 `LayoutCandidate`(与 spec 一致:`breaks` + `lines[]`) + - [x] 定义 `LineInfo`:`start/end/text/width/tokenCount/charCount` + - [x] 定义 `ScoreTerm`:`{ key: string; delta: number; detail?: any }` + - [x] 定义 `ScoreBreakdown`:`{ total: number; terms: ScoreTerm[] }` + - [x] 定义 `ScoredLayout`:`{ score; flags; tieKey; scoreBreakdown? }` + - [x] 明确评分函数需要的“可用宽度 availableWidth”传入方式(二选一,必须确定并全链路一致) + - [x] 方案 A:作为 `scoreLayout({ availableWidth, ... })` 的必填字段 + - [x] 方案 B:作为 `config.availableWidth`(不推荐,但允许) +- [x] 新建 `client/src/features/textWrap/scoring/index.ts` + - [x] 统一导出 types 与核心函数(后续实现) + +## 2. 权重与最小词表(写死客户端) + +- [x] 新建 `client/src/features/textWrap/scoring/weights.ts` + - [x] 以对象形式集中定义权重常量(全部整数) + - [x] 写入 plan.md 里的首版默认值(P_/R_ 系列) + - [x] 提供 `DEFAULT_WEIGHTS`(只读)与可选的 `mergeWeights(overrides)`(用于调用方覆盖) +- [x] 新建 `client/src/features/textWrap/scoring/lexicons.ts` + - [x] 定义 `Lexicons` 类型(与 spec 输入一致) + - [x] 写入最小词表(TC/EN:shift/accum/self,emotionWords 可选) + - [x] 提供 `DEFAULT_LEXICONS`(只读)与可选的 `mergeLexicons(overrides)` + - [x] 明确 `emotionPhrasesTC/EN`、`protectedPhrases` 首版可为空数组 + +## 3. Phrase 匹配(连续 token 完全匹配) + +- [x] 新建 `client/src/features/textWrap/scoring/phraseMatch.ts` + - [x] 定义 `PhraseSpan = { start: number; end: number; length: number }`(end 半开) + - [x] 实现 EN phrase 预处理 + - [x] 使用 `core-contract`:`normalizeWhitespace` + `tokenizeEN` + - [x] phrase 输入为原始字符串:先 normalizeWhitespace,再按空格切分为词序列 + - [x] 实现 TC phrase 预处理 + - [x] 使用 `grapheme-segmentation`:`segmentGraphemes` 得到 clusters + - [x] phrase 输入为原始字符串:按 grapheme clusters 切分 + - [x] 实现连续区间完全匹配查找(禁止跳 token / 禁止模糊) + - [x] 输出所有命中的 spans(稳定顺序:按 start 升序,start 相同按 length 降序) + - [x] 实现 `isSpanSplitByBreaks(span, breaks)`:判断 span 是否跨行(被拆分) + - [x] 单测覆盖: + - [x] EN:`but,` 命中 `but`;`rebuttal` 不命中 `but` + - [x] TC:包含 emoji/组合字符时不应被拆(依赖 grapheme 模块;这里只验证匹配结果可逆) + +## 4. 评分实现(按 10.1 固定顺序,整数累计) + +- [x] 新建 `client/src/features/textWrap/scoring/score.ts` + - [x] 定义 `scoreLayout(args)`(入参包含:layoutCandidate、lang、context、config、lexicons、availableWidth、debug?) + - [x] 实现 score 累计(必须严格按文档 10.1 的顺序) + - [x] 1) 情绪短语/受保护短语拆分惩罚(最高优先) + - [x] 匹配 emotionPhrases + protectedPhrases spans + - [x] 若 span 被拆分:`score -= P_*` + - [x] 采用方案 A:追加“更长短语优先”惩罚:`score -= phraseLength`(k=1 写死) + - [x] 设置 `flags.emotionSplit=true`(如需区分 protected 可扩展 detail,但保持确定性) + - [x] 2) 行长度(ideal/overMaxLen/tooShort) + - [x] `idealWidth = availableWidth * idealWidthRatio[context]`(取整规则必须固定:建议 `Math.round` 并写注释) + - [x] width-based 主项:`score += -abs(line.width - idealWidth)`(确保整数) + - [x] over/tooShort 按权重惩罚(阈值若来自文档,集中定义常量,禁止散落) + - [x] 3) Widow / 单字行惩罚(基于 tokenCount/charCount 口径写死) + - [x] 4) TC 标点断点奖励(使用断点 meta/kind 或基于行首字符推断;口径写注释) + - [x] 5) Shift/Accum/Self 断点奖励(TC/EN 词表) + - [x] EN 必须使用 `core-contract` 的全词等值匹配(复用 `normalizeENKeyword/matchENKeyword`) + - [x] 实现 spec 要求的折减:EmotionWord 与 Accum 同时命中时,Accum 奖励按 0.5 倍 + - [x] 因为整体使用整数:折减采用“整除/四舍五入”的固定规则(建议 `Math.floor(reward/2)` 并写注释) + - [x] 6) 尾行过短惩罚(SHORT_LASTLINE) + - [x] 记录 `scoreBreakdown` + - [x] 每个 term 写入 `{ key, delta, detail }` + - [x] terms 的产生顺序必须与 10.1 顺序一致(用于 debug 输出排序) + - [x] debug Top-3(不按 |delta|,按优先级) + - [x] 若 `debug=true`:只保留前 3 个“优先级最高的关键项” + - [x] 规则:先按 10.1 顺序,必要时同优先级保持稳定(按出现顺序) + - [x] 单测覆盖: + - [x] 评分顺序固定:构造 layout 触发 EmotionSplit,断言 breakdown 第一项为对应 key + - [x] 整数性:断言所有 delta 与 total 都是整数 + +## 5. tieKey 构造与比较(按 11 节固定顺序) + +- [x] 新建 `client/src/features/textWrap/scoring/tieKey.ts` + - [x] 实现 `buildTieKey(scoredLayout, layoutCandidate, config)`,严格按 11 节顺序产出 `Array` + - [x] emotionSplit(false 优先) + - [x] overflowed(false 优先) + - [x] lastLineWidth(更大优先:采用 `-lastLineWidth` 进入 tieKey,或在比较函数中反向比较,二选一且写注释) + - [x] 宽度分布均衡度:`maxWidth - minWidth`(更小优先) + - [x] 与理想切分点距离(更小优先;距离定义需与 breakpoints 模块/上游一致) + - [x] breaks 字典序(复用 `core-contract` 的 breaks 比较函数) + - [x] 单测覆盖: + - [x] 同分 layout:通过 tieKey 能稳定选出同一个最优 + - [x] Widget approx:即使 width 近似(wordCount/graphemeCount),tieKey 仍可比较(不抛错) + +## 6. 组合导出与回归测试 + +- [x] 完成 `client/src/features/textWrap/scoring/index.ts` 导出 + - [x] 导出 `DEFAULT_WEIGHTS/DEFAULT_LEXICONS` + - [x] 导出 `scoreLayout`、`buildTieKey` +- [x] 新建 `client/src/features/textWrap/scoring/__tests__/scoringTiebreak.test.ts` + - [x] 覆盖 EN/TC 两套 tokenization(EN 用 core,TC 用 grapheme) + - [x] 覆盖短语拆分惩罚 + 方案 A 的“更长短语更重惩罚” + - [x] 覆盖 debug Top-3 按优先级输出(非 |delta|) + - [x] 覆盖 tieKey 顺序项的比较方向(尤其 lastLineWidth) + +## 7. 文档与收尾(完成后必须做) + +- [x] 将 `spec_kit/Text Wrap/modules/scoring-tiebreak/tasks.md` 全部任务勾选完成 +- [x] 更新 `spec_kit/overview.md` + - [x] 标记 `scoring-tiebreak` 已完成编码(阶段性) + - [x] 在 overview 中记录本次变更文件清单(至少包含新增的客户端文件与测试文件) + diff --git a/spec_kit/Text Wrap/modules/search-engine-app/plan.md b/spec_kit/Text Wrap/modules/search-engine-app/plan.md new file mode 100644 index 0000000..3072b90 --- /dev/null +++ b/spec_kit/Text Wrap/modules/search-engine-app/plan.md @@ -0,0 +1,104 @@ +# search-engine-app(技术计划) + +## 1. 计划目标 + +在 **APP** 场景实现确定性的“断点组合搜索器”,使用 **DP + TopK** 生成候选 layout 并选出最优解: + +- 输入:`tokens[]`、`breakpoints[]`、`availableWidth`、`maxLines`、`lineMode` +- 过程:DP 状态转移 + TopK 维护(去重、排序、确定性 tie-break) +- 评分:调用 `scoring-tiebreak`(`scoreLayout` + `buildTieKey`) +- 输出:`bestLayout`(breaks/lines/wrappedText/meta)或返回 “无解/超长/宽度不可用” 的可解释原因(overflow-fallback 模块将统一处理最终语义) + +## 2. 约束与确定性口径 + +### 2.1 确定性(必须) + +同输入(含 configVersion/权重/词表/断点列表)必须同输出: + +- DP 遍历顺序固定(pos 升序、linesUsed 升序) +- nextPos 枚举顺序固定(按 `pos` 升序;最后补一个 `N` 结束边界) +- TopK 的排序固定:`score` 降序;再按 `tieKey` 逐项比较(11 节);再按 `breaks` 字典序(11.0A) +- 去重固定:同 `breaks` 序列只保留最优(score 更大;若相同用 tieKey) + +### 2.2 复杂度(必须可控) + +遵循文档 9.3 建议: + +- 默认 `TopK=10` +- 默认 `maxLines<=3`(或 4) +- 超长输入触发 `TOO_LONG`:TC>60 grapheme 或 EN>30 words(首版写死,后续 configVersion 管理) + +## 3. DP + TopK 设计(对应文档 9.1) + +### 3.1 状态定义 + +令 `N=tokens.length`,token 边界为 `pos in [0..N]`: + +- `dp[pos][linesUsed] = TopK partial layouts ending at token boundary pos` +- partial layout 至少包含: + - `pos`:当前结束位置 + - `breaks: number[]`:已选择的断点序列(升序) + - `lines: LineInfo[]`:已生成的行信息(start/end/text/width/tokenCount/charCount) + - `score: number` + `tieKey: number[]`(由 scoring-tiebreak 构造) + +### 3.2 转移(生成下一行) + +从状态 `(pos, linesUsed)` 选择 `nextPos` 生成新行区间 `[pos..nextPos)`: + +- nextPos 取值来自 `breakpoints` 中 `b.pos` 且 `b.pos > pos`,并按 `pos` 升序枚举 +- 必须额外允许 `nextPos = N`(结束边界) +- 每次转移需: + - 构造 lineText:使用 `core-contract/joinTokens` + - 测量 lineWidth:优先使用 `width-measurement/measureSliceWidthCached`(切片缓存),若不可用返回 `WIDTH_UNKNOWN` + - 应用硬约束过滤(见 3.3) + - 计算新 layout 的评分与 tieKey(调用 scoring-tiebreak) + - 插入 `dp[nextPos][linesUsed+1]` 并维护 TopK(去重、排序) + +### 3.3 硬约束(本模块先落地最小集) + +为了让 DP 行为稳定且可解释,首版在搜索层执行以下硬约束(其余放到评分): + +- **H1 不超宽**:若 lineWidth 可用且 `lineWidth > availableWidth`,该转移无效 +- **H5 最大行数**:`linesUsed+1 <= maxLines` +- **禁止空行**:`nextPos > pos`(区间非空) +- **TC 禁止行首标点(H6)**: + - 若 `lang=TC` 且 `pos>0` 且 `tokens[pos]` 属于 `tcPunctuations`,则该断点不可用(跳过) + +> 说明:protectedPhrases 的“span 内断点剔除”属于候选断点优化(可选),首版不在搜索层做剔除,依赖 scoring 的强惩罚淘汰(文档 6.2A / 10.2A)。 + +### 3.4 结束条件与 lineMode + +- 正常结束:到达 `pos=N` +- `lineMode=AUTO`: + - 从 `dp[N][1..maxLines]` 里选最优 +- `lineMode=FIXED`: + - 优先从 `dp[N][==maxLines]` 里选最优 + - 若无解:返回 “无解” 的原因与必要 meta(由 overflow-fallback 统一降级链路与打点) + +## 4. 代码落位(客户端) + +建议目录: + +- `client/src/features/textWrap/searchApp/` + - `types.ts` + - `dpTopK.ts`(DP 主流程 + TopK 维护) + - `topK.ts`(去重、插入、排序、截断) + - `constraints.ts`(H1/H6/TOO_LONG 判定) + - `index.ts` + - `__tests__/searchApp.test.ts` + +## 5. 测试计划(Vitest) + +- **确定性**:同输入运行 3 次输出完全一致(breaks/lines/wrappedText) +- **TopK 去重**:构造两条路径得到相同 breaks,断言只保留一个且为最优 +- **lineMode=FIXED**:有解时必须返回刚好 `maxLines`;无解时返回 reason(例如 `NO_CANDIDATE`) +- **TC 行首标点**:构造 tokens 在断点后行首为 `,`,断言该转移被禁止 +- **TOO_LONG**:TC>60 或 EN>30 时直接返回 `TOO_LONG` + +## 6. 完成定义(DoD) + +- DP+TopK 可运行,且确定性通过单测 +- 使用 `scoring-tiebreak` 进行评分与 tieKey 裁决 +- 搜索层硬约束最小集落地(H1/H6/空行/maxLines) +- 无解/超长/宽度不可用时返回明确 reason(不直接在本模块做最终 overflow) + diff --git a/spec_kit/Text Wrap/modules/search-engine-app/spec.md b/spec_kit/Text Wrap/modules/search-engine-app/spec.md new file mode 100644 index 0000000..2be898d --- /dev/null +++ b/spec_kit/Text Wrap/modules/search-engine-app/spec.md @@ -0,0 +1,51 @@ +# search-engine-app(子模块规范) + +## 子模块名称 + +search-engine-app(APP 搜索器:DP + TopK) + +## 目标描述 + +在 APP 场景实现多行换行的确定性组合搜索: + +- 使用 DP + TopK 生成候选 layout +- 对每个候选应用硬约束过滤 +- 调用 `scoring-tiebreak` 计算评分与 tieKey +- 保留 TopK 并在结束时选最优 + +要求性能可控且输出完全确定性。 + +## 输入/输出定义 + +### 输入 + +- `tokens: Token[]` +- `breakpoints: Breakpoint[]`(来自 `breakpoint-candidates`) +- `availableWidth: number` +- `maxLines: number` +- `lineMode: 'AUTO' | 'FIXED'` +- `context: 'APP'` +- `measure: { measureWidth?: fn; fontSpec?: any; cache: ... }`(来自 `width-measurement` 的实现) +- `config: { topK: number }` + +### 输出 + +- `bestLayout: { breaks: number[]; lines: string[]; wrappedText: string; meta?: { breaks: number[]; scoreTopTerms?: ... } }` +- 若无解:返回可解释的 fallback(由 `overflow-fallback` 统一处理语义) + +## 验收标准(可验证) + +- **确定性**:同输入(tokens/breakpoints/availableWidth/maxLines/configVersion)必定同输出(breaks 与 lines 完全一致) +- **TopK 维护一致**: + - K 默认 10(可配置但必须固定) + - 排序:score desc;tieKey 逐项比较 + - 去重:同 breaks 序列只保留最高分 +- **lineMode=FIXED 行为**: + - 优先选刚好 `maxLines` 的解;无解时按固定降级链路处理并打点 +- **复杂度可控**:超长输入触发裁剪/兜底并返回 reason(如 `TOO_LONG`) + +## 依赖与关联 + +- **依赖**:`core-contract`、`width-measurement`、`breakpoint-candidates`、`scoring-tiebreak`、`overflow-fallback` +- **被依赖**:`integration`、`golden-tests` + diff --git a/spec_kit/Text Wrap/modules/search-engine-app/tasks.md b/spec_kit/Text Wrap/modules/search-engine-app/tasks.md new file mode 100644 index 0000000..83ef086 --- /dev/null +++ b/spec_kit/Text Wrap/modules/search-engine-app/tasks.md @@ -0,0 +1,92 @@ +# search-engine-app(任务清单) + +> 目标:在 APP 场景实现 `DP + TopK` 的确定性组合搜索(对应文档 9.1),并接入 `scoring-tiebreak` 输出最优 layout。 + +## 0. 对齐与准备 + +- [x] 复核输入/输出与依赖 + - [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-app/spec.md` + - [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-app/plan.md` + - [x] 阅读 `设计说明文档/文档换行算法.md` 的 9.1/9.3/11 章节 +- [x] 创建客户端模块目录 + - [x] 新建 `client/src/features/textWrap/searchApp/` + +## 1. 类型定义与导出 + +- [x] 新建 `client/src/features/textWrap/searchApp/types.ts` + - [x] 定义 `LineMode = 'AUTO' | 'FIXED'` + - [x] 定义 `SearchAppConfig`(包含 `topK`、`tooLongThresholds` 等,首版给默认值) + - [x] 定义 `SearchAppInput`: + - [x] `tokens: Token[]` + - [x] `lang: 'TC' | 'EN'` + - [x] `breakpoints: Breakpoint[]` + - [x] `availableWidth: number` + - [x] `maxLines: number` + - [x] `lineMode: LineMode` + - [x] `measure: { contextProfile; fontSpec; measureWidthImpl }`(复用 width-measurement 类型) + - [x] `scoring: { config; lexicons; debug? }`(复用 scoring-tiebreak 类型) + - [x] 定义 `SearchAppResult`: + - [x] 成功:`bestLayout { breaks; lines; wrappedText; meta }` + - [x] 失败:`{ ok:false; reason:'TOO_LONG'|'WIDTH_UNKNOWN'|'NO_CANDIDATE'; meta }` +- [x] 新建 `client/src/features/textWrap/searchApp/index.ts` + - [x] 统一导出 types 与核心入口 `searchBestLayoutApp()` + +## 2. TopK 维护(确定性) + +- [x] 新建 `client/src/features/textWrap/searchApp/topK.ts` + - [x] 实现 `compareLayouts(a,b)`: + - [x] `score` 降序 + - [x] `tieKey` 逐项比较(数值越小越优) + - [x] `breaks` 字典序兜底(11.0A) + - [x] 实现去重:同 breaks 仅保留最优 + - [x] 实现 `insertTopK(list, cand, K)`:插入、去重、排序、截断(全程确定性) + +## 3. 约束与可解释失败原因 + +- [x] 新建 `client/src/features/textWrap/searchApp/constraints.ts` + - [x] 实现 `isTooLong(tokens, lang)`:TC>60 或 EN>30(首版写死) + - [x] 实现 `isLineStartPunctTC(tokens, pos, tcPunctuations)`:H6 + - [x] 实现 `isOverWidth(width, availableWidth)`:H1 + +## 4. DP + TopK 主流程 + +- [x] 新建 `client/src/features/textWrap/searchApp/dpTopK.ts` + - [x] 实现入口 `searchBestLayoutApp(input)` + - [x] 构造 dp:`dp[pos][linesUsed] = TopK[]` + - [x] 枚举顺序固定: + - [x] pos:0..N + - [x] linesUsed:0..maxLines-1 + - [x] nextPos:从 breakpoints 里取 `>pos` 的 pos 升序 + 追加 `N` + - [x] 每次转移: + - [x] 构造 lineText:使用 `joinTokens`(EN 默认空格,TC 使用 rawSeparators 拼接) + - [x] 测量 lineWidth:使用 `measureSliceWidthCached` + - [x] 过滤硬约束:H1/空行/H6/maxLines + - [x] 构造 layoutCandidate(breaks + lines[]) + - [x] 调用 `scoreLayout` + `buildTieKey` 得到 score/tieKey + - [x] 插入目标 dp 并维护 TopK + - [x] 结束选择: + - [x] AUTO:从 `dp[N][<=maxLines]` 选最优 + - [x] FIXED:优先 `dp[N][==maxLines]`,否则返回 `NO_CANDIDATE`(交给 overflow-fallback 再降级) + - [x] meta 输出: + - [x] `breaks` + - [x] `scoreTopTerms`(若 debug=true,从 scoring 输出 Top-3) + +## 5. 单测(Vitest) + +- [x] 新建 `client/src/features/textWrap/searchApp/__tests__/searchApp.test.ts` + - [x] 确定性:同输入运行多次结果一致 + - [x] TopK 去重:两条路径同 breaks 只保留一个 + - [x] FIXED:必须刚好 maxLines,否则失败 reason=NO_CANDIDATE + - [x] TC H6:行首标点导致的转移必须被禁止 + - [x] TOO_LONG:触发阈值直接返回 reason=TOO_LONG + +## 6. 收尾 + +- [x] 跑测试与类型检查 + - [x] `npm test` + - [x] `npx tsc --noEmit` +- [x] 将本 `tasks.md` 全部勾选完成 +- [x] 更新 `spec_kit/overview.md` + - [x] 标记 `search-engine-app` 已完成编码(阶段性) + - [x] 写入变更文件清单 + diff --git a/spec_kit/Text Wrap/modules/search-engine-widget/plan.md b/spec_kit/Text Wrap/modules/search-engine-widget/plan.md new file mode 100644 index 0000000..7b8e061 --- /dev/null +++ b/spec_kit/Text Wrap/modules/search-engine-widget/plan.md @@ -0,0 +1,115 @@ +# search-engine-widget(技术计划) + +## 1. 计划目标 + +在 **WIDGET** 场景实现确定性的 Beam Search(文档 9.2): + +- 每一行扩展时只保留 TopK partial layouts(beam) +- 每步扩展断点数 M 受控(更强裁剪) +- 支持 `widthMode='MEASURE' | 'APPROX'` +- 结束时从 `pos==N` 的 beams 中选最优;若无解返回可解释的失败原因(最终由 `overflow-fallback` 统一语义) + +## 2. 确定性与性能约束 + +### 2.1 确定性(必须) + +- beams 的遍历顺序固定(按当前 beams 排序后的顺序) +- nextPos 候选顺序固定(`pos` 升序) +- pruning 固定(先按候选优先级/位置排序,再取前 M) +- beam 的 TopK 维护固定:`score` 降序;再 `tieKey`;再 `breaks` 字典序 + +### 2.2 性能参数(写死默认值) + +来自文档 9.3: + +- `beamK=5` +- `expandM=12` + +## 3. Beam Search 设计(对应文档 9.2) + +令 `N=tokens.length`,token 边界为 `pos in [0..N]`。 + +### 3.1 beam 状态 + +一个 beam(partial layout)包含: + +- `pos`:当前已覆盖到的 token 边界 +- `breaks: number[]` +- `lines: LineInfo[]` +- `score` + `tieKey` +- `meta`(可选):debug Top-3 terms、approx reason 等 + +### 3.2 扩展 nextPos(每步最多 M) + +对每个 beam,从 `pos` 扩展到若干 `nextPos`: + +- nextPos 来源:`breakpoints[].pos > pos`(按 pos 升序) +- 额外包含 `nextPos=N`(结束边界) +- 为控制复杂度:对候选 nextPos 进行裁剪,仅保留前 `expandM` + +裁剪规则(确定性,首版简单可控): + +- 先取 `nextPos` 最小的 `expandM` 个(最短前缀优先,利于早结束) + +> 注:后续可升级为“优先级 + 距理想切分点近”裁剪,但首版以确定性与稳定为主。 + +### 3.3 宽度模式 + +- `widthMode='MEASURE'`: + - 若提供测量能力:使用 `measureSliceWidthCached` 获取 lineWidth + - 若测量不可用/失败:降级为 approx(记录 `reason=WIDTH_UNKNOWN` 或 `MEASURE_FAILED`),lineWidth 采用近似值 +- `widthMode='APPROX'`: + - 不调用测量;lineWidth 使用近似值: + - EN:`tokenCount` + - TC:`charCount` + - meta 标记 `reason=WIDTH_UNKNOWN` + +### 3.4 硬约束(最小集) + +与 App 一致的最小硬约束: + +- 禁止空行:`nextPos > pos` +- 最大行数:lineIndex 不超过 `maxLines` +- TC 禁止行首标点(H6):若 `tokens[nextPos]` 属于 `tcPunctuations` 则该转移无效 +- 超宽约束: + - MEASURE 模式下若 width 可用且 `lineWidth > availableWidth`,转移无效 + - APPROX 模式下不执行超宽硬过滤(因为单位不一致),交给评分处理 + +### 3.5 每轮保留 beamK + +对所有扩展出来的新 beams: + +- 使用 TopK 维护(去重同 breaks,排序按 score/tieKey/breaks) +- 全局只保留 `beamK` 个作为下一轮 beams + +### 3.6 结束选择 + +重复扩展最多 `maxLines` 轮后: + +- 优先从 beams 中选 `pos==N` 的最优 +- 若无 `pos==N`:返回 `NO_CANDIDATE`(由 overflow-fallback 统一兜底语义) + +## 4. 代码落位(客户端) + +- `client/src/features/textWrap/searchWidget/` + - `types.ts` + - `beam.ts` + - `topK.ts`(可复用 App 逻辑,首版复制一份避免耦合) + - `constraints.ts` + - `index.ts` + - `__tests__/searchWidget.test.ts` + +## 5. 测试计划(Vitest) + +- 确定性:同输入重复执行输出一致 +- 性能约束:beamK/expandM 生效(可通过构造大量断点断言扩展次数上限) +- widthMode=APPROX:仍能产出结果,且 meta.reason=WIDTH_UNKNOWN +- TC H6:行首标点转移被禁止 + +## 6. 完成定义(DoD) + +- Beam Search 可运行,且确定性单测通过 +- 宽度模式与降级 meta 符合 spec +- 性能参数默认值写死且可覆盖 +- 更新 overview 标记阶段性完成并记录变更文件 + diff --git a/spec_kit/Text Wrap/modules/search-engine-widget/spec.md b/spec_kit/Text Wrap/modules/search-engine-widget/spec.md new file mode 100644 index 0000000..74f80df --- /dev/null +++ b/spec_kit/Text Wrap/modules/search-engine-widget/spec.md @@ -0,0 +1,46 @@ +# search-engine-widget(子模块规范) + +## 子模块名称 + +search-engine-widget(WIDGET 搜索器:Beam Search) + +## 目标描述 + +在 WIDGET 场景实现确定性 Beam Search: + +- 每一行扩展时只保留 TopK partial layouts(beam) +- 每步扩展断点数 M 受控(更强裁剪) +- 支持 width unknown / approx mode +- 最终选择 pos==N 的最优解;无解走 `overflow-fallback` + +## 输入/输出定义 + +### 输入 + +- `tokens: Token[]` +- `breakpoints: Breakpoint[]` +- `availableWidth: number`(可为常量 profile) +- `maxLines: number` +- `context: 'WIDGET'` +- `measure: { widthMode: 'MEASURE' | 'APPROX' }` +- `config: { beamK: number; expandM: number }` + +### 输出 + +- `bestLayout: { breaks: number[]; lines: string[]; wrappedText: string; meta?: { breaks: number[]; scoreTopTerms?: ... } }` + +## 验收标准(可验证) + +- **确定性**:beam 扩展、剪枝、排序规则固定;同输入同输出 +- **性能约束生效**: + - beamK 默认 5 + - 每步扩展 M 默认不超过 12 +- **宽度不可用降级一致**: + - 进入 approx mode 时 meta 标记 `reason=WIDTH_UNKNOWN` + - 搜索仍可运行并输出可解释结果 + +## 依赖与关联 + +- **依赖**:`core-contract`、`width-measurement`、`breakpoint-candidates`、`scoring-tiebreak`、`overflow-fallback` +- **被依赖**:`integration`、`golden-tests` + diff --git a/spec_kit/Text Wrap/modules/search-engine-widget/tasks.md b/spec_kit/Text Wrap/modules/search-engine-widget/tasks.md new file mode 100644 index 0000000..63d5c19 --- /dev/null +++ b/spec_kit/Text Wrap/modules/search-engine-widget/tasks.md @@ -0,0 +1,78 @@ +# search-engine-widget(任务清单) + +> 目标:在 WIDGET 场景实现确定性 Beam Search(文档 9.2),支持 `widthMode=MEASURE/APPROX`,并输出最优 layout 或可解释失败原因。 + +## 0. 对齐与准备 + +- [x] 阅读并对齐口径 + - [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-widget/spec.md` + - [x] 阅读 `spec_kit/Text Wrap/modules/search-engine-widget/plan.md` + - [x] 阅读 `设计说明文档/文档换行算法.md` 的 9.2/9.3/11 章节 +- [x] 创建客户端模块目录 + - [x] 新建 `client/src/features/textWrap/searchWidget/` + +## 1. 类型定义与导出 + +- [x] 新建 `client/src/features/textWrap/searchWidget/types.ts` + - [x] 定义 `WidthMode = 'MEASURE' | 'APPROX'` + - [x] 定义 `SearchWidgetConfig`:`beamK`、`expandM`、`tooLongThresholds` + - [x] 定义 `SearchWidgetInput`: + - [x] `tokens/lang/breakpoints/availableWidth/maxLines/context='WIDGET'` + - [x] `measure: { widthMode; contextProfile?; fontSpec?; measureWidthImpl? }` + - [x] `scoring: { config; lexicons; debug? }` + - [x] 定义 `SearchWidgetResult`: + - [x] 成功:`bestLayout { breaks; lines; wrappedText; meta }` + - [x] 失败:`{ ok:false; reason:'TOO_LONG'|'NO_CANDIDATE'; meta? }` +- [x] 新建 `client/src/features/textWrap/searchWidget/index.ts` + - [x] 统一导出 types 与入口 `searchBestLayoutWidget()` + +## 2. TopK/BeamK 维护(确定性) + +- [x] 新建 `client/src/features/textWrap/searchWidget/topK.ts` + - [x] 实现 compare:score desc;tieKey asc;breaks 字典序 + - [x] 实现去重:同 breaks 只保留最优 + - [x] 实现 `insertTopK`(用于全局 beamK 保留) + +## 3. 约束与降级 meta + +- [x] 新建 `client/src/features/textWrap/searchWidget/constraints.ts` + - [x] `isTooLong`(TC>60 / EN>30) + - [x] `isLineStartPunctTC(tokens, nextPos, tcPunctuations)`(H6) + - [x] `approxWidth(line, lang)`(EN=tokenCount / TC=charCount) + +## 4. Beam Search 主流程 + +- [x] 新建 `client/src/features/textWrap/searchWidget/beam.ts` + - [x] 初始化 beams:`[{pos:0, breaks:[], lines:[], score:0}]` + - [x] 循环 lineIndex=1..maxLines + - [x] 对每个 beam 枚举 nextPos(breakpoints>pos + N) + - [x] 裁剪:仅取前 `expandM` 个 nextPos(pos 升序) + - [x] 构造行文本:`joinTokens` + - [x] 计算行宽: + - [x] widthMode=MEASURE:尝试 `measureSliceWidthCached`;失败则降级 approx 并 meta.reason 标记 + - [x] widthMode=APPROX:直接 approx,并 meta.reason=WIDTH_UNKNOWN + - [x] 硬约束过滤: + - [x] 禁止空行、maxLines、TC H6 + - [x] 若 width 可用且 > availableWidth:过滤(仅 MEASURE 可信) + - [x] 评分:`scoreLayout` + `buildTieKey` + - [x] 全局 newBeams 维护 TopK(beamK) + - [x] 结束:从 beams 中选 pos==N 最优,否则 NO_CANDIDATE + +## 5. 单测(Vitest) + +- [x] 新建 `client/src/features/textWrap/searchWidget/__tests__/searchWidget.test.ts` + - [x] 确定性:同输入重复执行结果一致 + - [x] widthMode=APPROX:仍可输出,且 meta.reason=WIDTH_UNKNOWN + - [x] 性能约束:expandM/beamK 生效(可统计测量/扩展次数) + - [x] TC H6:行首标点被禁止 + +## 6. 收尾 + +- [x] 跑测试与类型检查 + - [x] `npm test` + - [x] `npx tsc --noEmit` +- [x] 将本 `tasks.md` 全部勾选完成 +- [x] 更新 `spec_kit/overview.md` + - [x] 标记 `search-engine-widget` 已完成编码(阶段性) + - [x] 写入变更文件清单 + diff --git a/spec_kit/Text Wrap/modules/width-measurement/plan.md b/spec_kit/Text Wrap/modules/width-measurement/plan.md new file mode 100644 index 0000000..85fdeb2 --- /dev/null +++ b/spec_kit/Text Wrap/modules/width-measurement/plan.md @@ -0,0 +1,138 @@ +# width-measurement(技术计划) + +## 1. 计划目标 + +基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,落地 Text Wrap 的“宽度测量与降级(approx mode)”能力,保证: + +- APP 场景可进行**可靠的文本宽度测量**(成熟方案,结果可缓存) +- WIDGET 场景允许不测量/测量失败时**确定性降级**(EN=wordCount,TC=charCount/graphemeCount) +- 缓存 key、错误处理与降级路径固定,保证**同输入同输出**(确定性) +- `fontSpec` 缺失字段直接报错(强制调用方补齐,避免隐式默认导致跨端漂移) +- `availableWidth` 由“本机设备侧/上层”传入,本模块不内置 widgetProfiles 常量 + +## 2. 默认技术决策(本计划采用) + +### 2.1 测量方案(成熟方法) + +- **APP(RN/Expo)**:采用成熟的“离屏文本测量”库实现 `measureWidth(text, fontSpec)` + - 建议选型:`react-native-text-size`(或团队已有的等价成熟方案) + - 理由:可直接测量给定字体参数下的文本宽度,避免用 UI 渲染 onLayout 造成异步/不确定性 + +> 说明:本计划把“测量实现”作为本子模块交付的一部分,而不是仅定义接口;但仍保留注入点,便于替换实现或做平台差异适配。 + +### 2.2 缓存策略(由我统一定义) + +采用“两级缓存 + 有上限”的策略: + +1. **字符串测量缓存**:`(text, fontSpecKey, contextProfile) -> width` +2. **切片测量缓存**:`(start, end, fontSpecKey, contextProfile) -> width` + +并且: + +- 缓存采用**模块级常驻缓存**(跨 `wrapText()` 多次调用复用),提升性能 +- 缓存容量必须有上限(建议 LRU 或“超限清空 + 打点”),避免内存无限增长 + +### 2.3 fontSpec 严格性(缺失就报错) + +`fontSpec` 在 APP 测量路径下必须包含: + +- `fontSize` +- `fontFamily` +- `fontWeight` + +缺失任意字段时: + +- **直接抛错**(错误信息必须为简体中文,并指出缺失字段与调用方应补齐的位置) +- 不允许在测量层做“隐式默认值”(避免跨端不一致与线上难定位) + +### 2.4 contextProfile 的口径 + +为保证缓存与确定性,本模块定义 `contextProfile`: + +- APP:`APP||`(按需扩展,但必须固定字段顺序与拼接方式) +- WIDGET:`WIDGET|`(如果上层传入 widgetSize,可写入;否则仅 WIDGET) + +> 注意:你已确认 `availableWidth` 由设备侧传入,本模块不内置 widgetProfiles;但缓存仍需要一个 profile 字段区分 APP/WIDGET。 + +## 3. API 设计(实现阶段稳定契约) + +### 3.1 对外接口(建议) + +- `buildFontSpecKey(fontSpec) -> string` +- `measureWidthCached({ text, context, fontSpec, contextProfile }) -> { width: number | null; meta: { isApprox: boolean; reason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' } }` +- `measureSliceWidthCached({ tokens, start, end, joinTokens, ... }) -> { width: number | null; meta: ... }` + +### 3.2 approx mode 口径(必须确定性) + +当出现任一情况时进入 approx mode(返回 `width=null`,并由上层按 approx 口径计算 lineLen): + +- `measureWidth` 不可用 +- `measureWidth` 抛错 +- `measureWidth` 返回 NaN/Infinity/负数 +- context=WIDGET 且上层选择不测量 + +approx 的“长度单位”口径固定为: + +- EN:`wordCount` +- TC:`graphemeCount`(优先;若上层仅有 charCount,则使用 charCount,但必须在实现中写死选择) + +并必须打点 reason: + +- `WIDTH_UNKNOWN`:没有测量能力/不启用测量 +- `MEASURE_FAILED`:测量抛错或返回非法值 + +## 4. 实现步骤(按落地顺序) + +### 4.1 定义类型与错误 + +- 定义 `FontSpec`、`ContextProfile`、`MeasureResult` 类型 +- 定义 `MissingFontSpecError`(或统一错误码),错误信息简体中文 + +### 4.2 实现 fontSpecKey + +实现 `fontSpecKey = fontFamily|fontWeight|fontSize`: + +- 字段顺序固定 +- `fontSize` 转为字符串(禁止浮点格式漂移:建议 `String(fontSize)`,并要求输入是 number 且有限) + +### 4.3 实现缓存容器 + +- 实现一个带上限的缓存(LRU 优先;若不引入依赖,先用 Map + 超限清空) +- Key 生成必须确定性: + - `textKey = ||` + - `sliceKey = |||` + +### 4.4 接入成熟测量库(APP) + +- 封装 `measureWidthImpl(text, fontSpec) -> number` +- 对返回值做校验(有限、非负) +- 失败捕获并走 approx mode(并打 `MEASURE_FAILED`) + +### 4.5 WIDGET 策略 + +- 默认允许调用方传入 `measureWidthImpl`(如果 Widget 侧实现了测量) +- 若不传/不启用:直接 approx mode,并 reason=`WIDTH_UNKNOWN` + +## 5. 测试计划(Vitest) + +### 5.1 纯函数与缓存测试(必须) + +- `fontSpecKey`:同输入同输出;缺字段抛错 +- 缓存命中:同 key 不重复调用底层 `measureWidthImpl` +- 缓存隔离:不同 `contextProfile/fontSpecKey` 不互相污染 + +### 5.2 降级路径测试(必须) + +- 缺少 `measureWidthImpl` -> approx mode + reason=`WIDTH_UNKNOWN` +- `measureWidthImpl` 抛错/返回 NaN -> approx mode + reason=`MEASURE_FAILED` + +> 说明:测量库本身的准确性不在单测中做像素级断言;单测只验证“缓存与降级语义确定性”。 + +## 6. 完成定义(DoD) + +- APP/WIDGET 下 `width` 返回语义清晰:可测量返回 number,不可测量返回 null +- `fontSpec` 缺字段必定报错(简体中文错误信息) +- 缓存 key 与容量策略确定性,且有上限 +- approx mode 触发条件、reason 标记、EN/TC 近似口径写死 +- 单测覆盖缓存命中/隔离与降级路径 + diff --git a/spec_kit/Text Wrap/modules/width-measurement/spec.md b/spec_kit/Text Wrap/modules/width-measurement/spec.md new file mode 100644 index 0000000..a16ff5a --- /dev/null +++ b/spec_kit/Text Wrap/modules/width-measurement/spec.md @@ -0,0 +1,51 @@ +# width-measurement(子模块规范) + +## 子模块名称 + +width-measurement(宽度测量与降级) + +## 目标描述 + +提供统一的宽度测量接口与缓存策略,并定义“宽度不可用”时的确定性降级行为(approx mode),确保 APP 与 WIDGET 在测量能力差异下仍能: + +- 保持搜索/评分流程可运行 +- 输出可解释(meta 打点) +- 性能稳定(缓存与上限) + +## 输入/输出定义 + +### 输入 + +- `context: 'APP' | 'WIDGET'` +- `fontSpec?: { fontSize: number; fontWeight?: string; fontFamily?: string }` +- `measureWidth?: (text: string, fontSpec) => number`(可选注入) +- `text: string` +- `slice?: { start: number; end: number }`(可选:段落切片测量) + +### 输出 + +- `width: number | null` + - 当不可用/失败时返回 `null`(触发 approx mode) +- `meta?: { isApprox: boolean; reason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' }` + +并提供缓存约定(逻辑输出): + +- 字符串测量缓存 key:`(text, fontSpecKey, contextProfile)` +- 切片测量缓存 key:`(start, end, fontSpecKey, contextProfile)` + +## 验收标准(可验证) + +- **测量一致**:在 APP 注入 `measureWidth` 时,同一输入重复测量命中缓存(不会重复计算) +- **失败降级**:`measureWidth` 缺失/抛错/返回 NaN 时: + - 输出 `width=null` + - meta 标记 `isApprox=true` 且 reason 可追踪 +- **approx mode 口径一致**: + - EN:宽度近似值使用 `wordCount` + - TC:宽度近似值使用 `charCount`(或 grapheme 数) +- **确定性**:相同输入在相同 contextProfile 下,测量与降级行为一致 + +## 依赖与关联 + +- **被依赖**:`search-engine-app`、`search-engine-widget`、`overflow-fallback` +- **依赖**:`core-contract`(fontSpecKey 规范化、切片文本重组) + diff --git a/spec_kit/Text Wrap/modules/width-measurement/tasks.md b/spec_kit/Text Wrap/modules/width-measurement/tasks.md new file mode 100644 index 0000000..1f9fca5 --- /dev/null +++ b/spec_kit/Text Wrap/modules/width-measurement/tasks.md @@ -0,0 +1,160 @@ +# width-measurement(任务清单) + +> 对应计划:`spec_kit/Text Wrap/modules/width-measurement/plan.md` +> +> 状态含义:`[ ]` 未完成,`[x]` 已完成。 +> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。 + +--- + +## 0. 任务标记规则 + +- 用勾选框标记执行状态: + - `[ ]` 未完成 + - `[x]` 已完成 +- 每个任务必须可独立验收(有明确产出与检查方式)。 +- 所有代码注释必须为简体中文,并把“缓存 key / 降级语义 / 报错口径”写死,避免后续模块漂移。 + +--- + +## 1. 前置检查(依赖与约束确认) + +- [x] 1.1 确认 `availableWidth` 由设备侧传入(本模块不内置 widgetProfiles) + - **验收**:在本模块实现中不引入任何固定宽度常量表;仅消费上层传入的宽度与 profile。 + +- [x] 1.2 确认 APP 测量方案采用成熟库(默认 `react-native-text-size`) + - **验收**:`client/package.json` 中存在该依赖(或团队等价成熟方案),并且测量封装函数仅依赖该库/注入点。 + +--- + +## 2. 依赖与目录骨架(客户端侧实现) + +- [x] 2.1 新建目录 `client/src/features/textWrap/measure/` + - **产出**(建议文件): + - `types.ts`(FontSpec/ContextProfile/MeasureResult) + - `errors.ts`(缺字段报错) + - `fontSpecKey.ts` + - `cache.ts`(两级缓存 + 上限) + - `measureWidthImpl.ts`(对接成熟测量库) + - `measureWidthCached.ts` + - `measureSliceWidthCached.ts` + - `__tests__/widthMeasurement.test.ts` + - `index.ts`(统一导出) + - **验收**:目录存在,TS 可正常 import(不报路径错误)。 + +- [x] 2.2 新增依赖 `react-native-text-size`(如项目未安装) + - **命令**(示例): + - `cd client && npm install react-native-text-size` + - **验收**: + - 安装成功 + - `npm test` 不受影响(后续任务再补充本模块测试) + +--- + +## 3. fontSpec 强校验(缺失直接报错) + +- [x] 3.1 定义 `FontSpec` 类型(必须字段:`fontSize/fontFamily/fontWeight`) + - **验收**:类型层面可表达“必填字段”,并在运行时也做校验。 + +- [x] 3.2 实现运行时校验与错误(简体中文) + - **要求**: + - 缺失任一字段直接抛错 + - 错误信息包含:缺失字段名 + 建议调用方补齐的位置(例如 `wrapText({ fontSpec: ... })`) + - 禁止隐式默认值 + - **验收**:单测断言会抛错且错误信息包含缺失字段名。 + +--- + +## 4. fontSpecKey 与 contextProfile(确定性 key 体系) + +- [x] 4.1 实现 `buildFontSpecKey(fontSpec)` + - **规则**:`fontFamily|fontWeight|fontSize`(顺序固定) + - **验收**:同输入同输出;fontSize 非有限数时报错。 + +- [x] 4.2 定义 `contextProfile` 拼接规则并实现 helper + - **要求**: + - APP:`APP||`(字段顺序固定) + - WIDGET:`WIDGET|`(无 widgetSize 时为 `WIDGET`) + - **验收**:输出字符串稳定;不同 profile 必须产生不同缓存 key。 + +--- + +## 5. 两级缓存(有上限 + 确定性) + +- [x] 5.1 实现缓存容器(模块级常驻) + - **要求**: + - 字符串测量缓存(textKey) + - 切片测量缓存(sliceKey) + - 容量上限策略:LRU 优先;若不引入依赖,先 Map + 超限清空(并预留打点钩子) + - **验收**:单测可验证缓存命中会减少底层测量调用次数。 + +- [x] 5.2 定义 key 生成规则并实现 + - **规则**: + - `textKey = ||` + - `sliceKey = |||` + - **验收**:key 生成不依赖对象遍历顺序;同输入同 key。 + +--- + +## 6. 测量实现与降级(approx mode) + +- [x] 6.1 实现 `measureWidthImpl(text, fontSpec)`(APP) + - **要求**: + - 依赖成熟库测量宽度 + - 返回值必须校验:有限且非负 + - **验收**:在单测中用 mock 替代真实库,验证封装逻辑与校验逻辑即可(不做像素级断言)。 + +- [x] 6.2 实现 `measureWidthCached(...)` + - **行为**: + - 正常测量:返回 `{ width:number, meta:{ isApprox:false } }` + - 进入 approx:返回 `{ width:null, meta:{ isApprox:true, reason } }` + - **approx 触发条件**(任一满足): + - 未提供测量能力 / context=WIDGET 且上层不启用测量 -> `WIDTH_UNKNOWN` + - 抛错/NaN/Infinity/负数 -> `MEASURE_FAILED` + - **验收**:单测覆盖两类 reason。 + +- [x] 6.3 实现 `measureSliceWidthCached(...)`(切片测量) + - **要求**: + - 通过 `joinTokens(start,end)` 生成切片文本 + - 使用切片缓存避免 DP 反复测量 + - **验收**:单测验证同 sliceKey 不重复调用底层测量。 + +--- + +## 7. 单元测试(Vitest) + +- [x] 7.1 新建 `__tests__/widthMeasurement.test.ts` 并覆盖以下用例 + - **fontSpec 报错**:缺字段必抛错(错误信息含字段名) + - **缓存命中**:同 key 不重复调用底层测量 mock + - **缓存隔离**:不同 `contextProfile/fontSpecKey` 不互相污染 + - **降级**: + - 缺测量能力 -> `width=null` + `WIDTH_UNKNOWN` + - 测量抛错/NaN -> `width=null` + `MEASURE_FAILED` + - **验收**:`npm test` 稳定通过。 + +--- + +## 8. 最终自检清单(合入前) + +- [x] 8.1 `npm test` 通过(包含本模块新增用例) + - **验收**:不影响现有测试文件。 + +- [x] 8.2 `npx tsc --noEmit` 通过(或项目既有 TS 检查命令通过) + - **验收**:无类型错误。 + +- [x] 8.3 注释与口径自检(简体中文) + - **检查点**: + - `fontSpec` 缺字段“必须报错” + - key 生成规则与 contextProfile 口径 + - approx mode 的 reason 语义(WIDTH_UNKNOWN / MEASURE_FAILED) + - **验收**:后续模块开发者只看代码也不会产生歧义。 + +--- + +## 9. 文档回写(任务清单执行完毕后必须做) + +- [x] 9.1 在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充执行状态 + - **建议写法**: + - 增加一行:`- **已完成编码(阶段性)**:width-measurement(宽度测量与降级)` + - **验收**:overview 能反映该子模块已完成,便于全局追踪。 + diff --git a/spec_kit/Text Wrap/spec.md b/spec_kit/Text Wrap/spec.md new file mode 100644 index 0000000..83d54ed --- /dev/null +++ b/spec_kit/Text Wrap/spec.md @@ -0,0 +1,109 @@ +# Text Wrap(大需求总览) + +> 本文件只保留高层背景、总览与模块拆分;各子模块可独立实现与验收。 +> 详细算法口径以 `设计说明文档/文档换行算法.md`(v1.2.1)为准。 + +## 1. Overview(背景/目标/非目标) + +Home 页面与 iOS 桌面小组件(Widget)都会展示“情绪文案/正念短句”。若依赖系统默认换行,会出现不可控、不可解释、跨端不一致的问题。 + +本需求要求把“文案换行算法”从 UI 组件中**独立成一个可复用模块**,用于: + +- Home 页面文案渲染 +- Widget 文案渲染(允许测量能力不同,但规则与决策必须一致) + +### 目标 + +- **确定性**:同输入(含配置版本)必定同输出 +- **跨端一致口径**:索引体系、断点定义、关键词命中与 tie-break 规则完全一致 +- **可治理**:支持 debug meta、线上打点、Golden Case 回归 +- **可复用**:算法作为纯函数核心,UI 仅消费 `lines[]/wrappedText/meta` + +### 非目标 + +- 不做语义理解/情绪识别/机器学习 +- 不做通用排版引擎 +- 不承诺 Widget 场景做到像 App 一样的像素级测量(Widget 可使用估算/常量) + +## 2. 对外接口(统一口径) + +模块对外暴露 `wrapText()`(纯函数): + +```ts +wrapText({ + text: string, + lang: 'TC' | 'EN', + availableWidth: number, + maxLines: number, + context: 'APP' | 'WIDGET', + fontSpec?: { fontSize: number; fontWeight?: string; fontFamily?: string }, + overflowMode?: 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT', + lineMode?: 'AUTO' | 'FIXED', + constraints?: { + protectedPhrases?: string[]; + forbiddenBreakRanges?: Array<{ start: number; end: number }>; + }, + configVersion?: string, + debug?: boolean +}) => { + lines: string[]; + wrappedText: string; + meta?: { + configVersion?: string; + fallback_type?: 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT'; + overflow_type?: 'NONE' | 'ELLIPSIS' | 'CLIP'; + reason?: 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'WIDOW' | 'PARTICLE' | 'TOO_LONG'; + breaks?: number[]; + scoreTopTerms?: Array<{ key: string; delta: number; detail?: any }>; + } +} +``` + +## 3. 模块拆分(modules)与依赖关系 + +### 3.1 `modules/` 目录结构 + +```text +spec_kit/Text Wrap/ +├ spec.md +└ modules/ + ├ core-contract/spec.md + ├ grapheme-segmentation/spec.md + ├ width-measurement/spec.md + ├ breakpoint-candidates/spec.md + ├ scoring-tiebreak/spec.md + ├ search-engine-app/spec.md + ├ search-engine-widget/spec.md + ├ overflow-fallback/spec.md + ├ golden-tests/spec.md + └ integration/spec.md +``` + +### 3.2 模块职责概览 + +- **`core-contract`**:EN/TC token 索引体系、文本重组、EN 关键词命中“全词等值”、配置/版本化与确定性比较口径 ✅ +- **`grapheme-segmentation`**:TC 字符簇分割的推荐实现与无库兜底 + 回归样例 ✅ +- **`width-measurement`**:测量接口、缓存、宽度不可用降级(approx mode)与打点 ✅ +- **`breakpoint-candidates`**:候选断点生成(PUNCT/SPACE/BALANCE)、去重排序、约束过滤与规模上限 ✅ +- **`scoring-tiebreak`**:评分项顺序、可解释 breakdown、tieKey/tie-break 规则与整数分/EPS ✅ +- **`search-engine-app`**:APP 场景 DP + TopK 的确定性实现 +- **`search-engine-widget`**:WIDGET 场景 Beam Search 的确定性实现 +- **`overflow-fallback`**:ELLIPSIS/CLIP/SYSTEM_DEFAULT 语义、ellipsis 细则与兜底链路 +- **`golden-tests`**:Golden Cases 与性质测试(确定性、近似单调性、maxLines 不变差) +- **`integration`**:Home/Widget 接入约定(参数映射、渲染策略、fallback 语义对齐) + +## 4. 实现顺序(推荐) + +> 目标是“先锁口径,再做搜索与评分,再做溢出与回归”,避免后期重构。 + +1. `core-contract` +2. `grapheme-segmentation` +3. `width-measurement` +4. `breakpoint-candidates` +5. `scoring-tiebreak` +6. `search-engine-app` +7. `search-engine-widget` +8. `overflow-fallback` +9. `golden-tests` +10. `integration` + diff --git a/spec_kit/overview.md b/spec_kit/overview.md index d3b35b8..72dc344 100644 --- a/spec_kit/overview.md +++ b/spec_kit/overview.md @@ -90,6 +90,86 @@ - 清理未接入编译的 WidgetKit 骨架残留:移除磁盘上的 `client/ios/MindfulnessWidget/` 文件,并从 `client/ios/client.xcodeproj/project.pbxproj` 删除对应工程引用(避免 Xcode 显示幽灵文件) - 修复 Xcode Archive 偶发显示 “Generic Xcode Archive”:在共享 scheme `Hey Mama` 的 Archive Post-actions 自动补齐 `.xcarchive/Info.plist` 的 `ApplicationProperties`,并在缺失时补齐 `Name`/`SchemeName` + 自检提示(根治 Organizer 无法识别主 App、无法分发/上传 TestFlight 的问题) +## Text Wrap + +- **目标**:将 Home 与 Widget 的文案换行算法从 UI 中独立成可复用模块,输出稳定、可控、可解释的换行结果(同输入同输出) +- **核心范围**:`wrapText()` 纯函数入口、TC/EN token 口径与索引体系、候选断点生成、DP/Beam 搜索、硬约束/评分/tie-break、溢出与兜底、debug meta 与 Golden Cases +- **阶段产物**: + - `spec_kit/Text Wrap/spec.md` +- **已完成编码(阶段性)**:core-contract(核心口径与契约) +- **已完成编码(阶段性)**:grapheme-segmentation(TC 字符簇分割) +- **已完成编码(阶段性)**:width-measurement(宽度测量与降级) + - **修复**:`measureSliceWidthCached` 的切片缓存 key 增加 `sliceText` hash,避免不同文本的相同 `(start,end)` 发生串缓存 +- **已完成编码(阶段性)**:breakpoint-candidates(候选断点生成与裁剪) + - **变更文件**: + - `client/src/features/textWrap/breakpoints/types.ts` + - `client/src/features/textWrap/breakpoints/enCandidates.ts` + - `client/src/features/textWrap/breakpoints/tcCandidates.ts` + - `client/src/features/textWrap/breakpoints/filterAndDedup.ts` + - `client/src/features/textWrap/breakpoints/generateBreakpoints.ts` + - `client/src/features/textWrap/breakpoints/index.ts` + - `client/src/features/textWrap/breakpoints/__tests__/generateBreakpoints.test.ts` + - `spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md` + - `spec_kit/Text Wrap/modules/breakpoint-candidates/tasks.md` +- **已完成编码(阶段性)**:scoring-tiebreak(评分模型与确定性裁决) + - **变更文件**: + - `client/src/features/textWrap/scoring/types.ts` + - `client/src/features/textWrap/scoring/weights.ts` + - `client/src/features/textWrap/scoring/lexicons.ts` + - `client/src/features/textWrap/scoring/phraseMatch.ts` + - `client/src/features/textWrap/scoring/score.ts` + - `client/src/features/textWrap/scoring/tieKey.ts` + - `client/src/features/textWrap/scoring/index.ts` + - `client/src/features/textWrap/scoring/__tests__/scoringTiebreak.test.ts` + - `spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md` + - `spec_kit/Text Wrap/modules/scoring-tiebreak/tasks.md` +- **已完成编码(阶段性)**:search-engine-app(APP 搜索器:DP + TopK) + - **变更文件**: + - `client/src/features/textWrap/searchApp/types.ts` + - `client/src/features/textWrap/searchApp/topK.ts` + - `client/src/features/textWrap/searchApp/constraints.ts` + - `client/src/features/textWrap/searchApp/dpTopK.ts` + - `client/src/features/textWrap/searchApp/index.ts` + - `client/src/features/textWrap/searchApp/__tests__/searchApp.test.ts` + - `spec_kit/Text Wrap/modules/search-engine-app/plan.md` + - `spec_kit/Text Wrap/modules/search-engine-app/tasks.md` + - `client/src/features/textWrap/measure/measureSliceWidthCached.ts` +- **已完成编码(阶段性)**:search-engine-widget(WIDGET 搜索器:Beam Search) + - **变更文件**: + - `client/src/features/textWrap/searchWidget/types.ts` + - `client/src/features/textWrap/searchWidget/topK.ts` + - `client/src/features/textWrap/searchWidget/constraints.ts` + - `client/src/features/textWrap/searchWidget/beam.ts` + - `client/src/features/textWrap/searchWidget/index.ts` + - `client/src/features/textWrap/searchWidget/__tests__/searchWidget.test.ts` + - `spec_kit/Text Wrap/modules/search-engine-widget/plan.md` + - `spec_kit/Text Wrap/modules/search-engine-widget/tasks.md` +- **已完成编码(阶段性)**:overflow-fallback(溢出与兜底) + - **变更文件**: + - `client/src/features/textWrap/overflow/types.ts` + - `client/src/features/textWrap/overflow/ellipsis.ts` + - `client/src/features/textWrap/overflow/fallback.ts` + - `client/src/features/textWrap/overflow/index.ts` + - `client/src/features/textWrap/overflow/__tests__/overflowFallback.test.ts` + - `spec_kit/Text Wrap/modules/overflow-fallback/plan.md` + - `spec_kit/Text Wrap/modules/overflow-fallback/tasks.md` +- **已完成(阶段性)**:golden-tests(Golden Cases 与性质测试) + - **变更文件**: + - `client/src/features/textWrap/golden/fixtures.ts` + - `client/src/features/textWrap/golden/__tests__/golden.test.ts` + - `spec_kit/Text Wrap/modules/golden-tests/plan.md` + - `spec_kit/Text Wrap/modules/golden-tests/tasks.md` +- **已完成编码(阶段性)**:integration(Home / Widget 接入) + - **变更文件**: + - `client/src/features/textWrap/types.ts` + - `client/src/features/textWrap/wrapText.ts` + - `client/src/features/textWrap/index.ts` + - `client/src/features/textWrap/__tests__/wrapText.integration.test.ts` + - `spec_kit/Text Wrap/modules/integration/plan.md` + - `spec_kit/Text Wrap/modules/integration/tasks.md` + - **接入情况**: + - Home(APP):已在 `client/app/(app)/home.tsx` 接入 `wrapText()` 渲染 `wrappedText`(含 `\n`) + ## Splash Consent - **目标**:实现开屏页(使用 `client/assets/images/index/` 图片资源),首次加载展示渐变同意按钮并提供隐私/协议入口 diff --git a/设计说明文档/文档换行算法.md b/设计说明文档/文档换行算法.md new file mode 100644 index 0000000..d16742a --- /dev/null +++ b/设计说明文档/文档换行算法.md @@ -0,0 +1,877 @@ +# 情绪文案换行算法规范与实现映射说明书 v1.2(裁决补充版,可直接实现)— **实现口径补充 v1.2.1** + +> 本文档用于指导 **情绪文案自动换行算法** 的完整实现(可直接据此写代码)。 +> 目标不是做语义理解,而是通过 **规则 + 参数 + 确定性搜索与决策**, +> 在多语言、多行数、多尺寸(App/Widget)场景下,输出稳定、可控、可解释的换行结果。 +> +> **本版本为「裁决补充版 + 实现口径补充」**:已将实现时最容易产生歧义的口径点写入原文合适位置,确保跨端一致。 + +--- + +## 0. 快速摘要(给实现者) + +你需要实现一个纯函数模块: + +* **输入**:text、lang、availableWidth、maxLines、fontSpec(App 强烈建议)、context(APP/WIDGET)、可选 constraints、可选 overflowMode +* **输出**:lines[](<=maxLines)、wrappedText、可选 meta + +核心流程(必须确定性): + +1. Tokenize(按语言) +2. 生成候选断点 breakpoints(控规模) +3. 搜索断点组合(DP+TopK 或 Beam),生成候选 layout +4. 对 layout 做硬约束过滤 + 打分 +5. tie-break 固定顺序选最优 +6. 溢出/兜底处理 + 打点 + +--- + +## 1. 设计目标(Why) + +### 1.1 核心目标 + +* 控制阅读节奏:让用户“自然停顿” +* 放大情绪关键词:情绪词/短语尽量靠近行尾或独立成行 +* 多端稳定:App 精确测量、Widget 常量估算也能稳定 +* **同输入 → 同输出(确定性)** +* 可缓存:同 key 重复调用不重复计算 +* 可治理:兜底/溢出可打点 + +### 1.2 非目标 + +* 不做情绪识别 / 语义理解 +* 不做通用排版引擎 +* 不依赖机器学习 + +--- + +## 2. 数据结构与接口(What) + +### 2.1 公开接口(建议) + +```ts +wrapText({ + text: string, + lang: 'TC' | 'EN', + availableWidth: number, + maxLines: number, + context: 'APP' | 'WIDGET', + fontSpec?: { fontSize: number; fontWeight?: string; fontFamily?: string }, + overflowMode?: 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT', + lineMode?: 'AUTO' | 'FIXED', + constraints?: { + protectedPhrases?: string[]; // 尽量不跨行 + forbiddenBreakRanges?: Array<{start: number; end: number}>; // token index 区间内禁止断点 + }, + configVersion?: string, + debug?: boolean +}) => { + lines: string[]; + wrappedText: string; + meta?: DebugMeta; +} +``` + +### 2.2 内部结构(建议) + +**Token** + +* EN:word token(保留原始字符串与长度) +* TC:grapheme cluster token(字符簇,避免拆 emoji/ZWJ) + +```ts +Token { + text: string + type: 'WORD' | 'SPACE' | 'PUNCT' | 'GRAPHEME' + isPunct?: boolean + isEmojiCluster?: boolean +} +``` + +**Breakpoint**(断点位置是 token 边界) + +```ts +Breakpoint { + pos: number; // 断点在 token 边界:切分为 [0..pos) + [pos..] + kind: 'PUNCT' | 'SPACE' | 'BALANCE' | 'SHIFT' | 'ACCUM' | 'SELF' | 'OTHER'; + priority: number; // 候选断点生成阶段使用(先截断) +} +``` + +**LineSegment**(一行) + +```ts +LineSegment { + start: number; + end: number; + text: string; + width: number; + tokenCount: number; + charCount: number; + endsWithEmotion?: boolean; +} +``` + +**Layout(候选方案)** + +```ts +Layout { + breaks: number[]; // 断点序列,例如 [pos1, pos2, ...] + lines: LineSegment[]; + score: number; + tieKey?: (number | string)[]; + flags: { + emotionSplit?: boolean; + overflowed?: boolean; + fallback?: boolean; + } + scoreBreakdown?: ScoreBreakdown; +} +``` + +--- + +## 2.3(新增)索引体系与文本重组(必须定义,确保跨端一致) + +> 最常见的实现偏差来自:token 是否包含空格、pos 到底切在哪里、以及如何拼回 text。以下规则必须统一。 + +### 2.3.1 EN 索引约定 + +* EN token 序列 **只包含 WORD**(标点默认贴附在词内),不生成 SPACE token。 +* 断点 `pos` 表示:在第 `pos` 个词 **之前** 断开。 + + * 行区间 `[start..end)` 表示 `tokens[start] ... tokens[end-1]` +* 文本重组:默认用单空格 `" "` join(或使用原始空白映射,见 2.3.3)。 + +**例**:`"I am so tired"` → tokens = `[I, am, so, tired]` + +* breaks=[2] → lines: `"I am"` / `"so tired"` + +### 2.3.1A(裁决补充)EN 标点处理:极简派(必须) + +为保证跨端实现一致,EN 标点采用**极简派**约束: + +* 所有标点均视为 **词内字符**(token.text 的一部分),不额外拆分为独立 token。 + 例如: + + * `"tired."` 是一个 token + * `"Wait..."` 是一个 token + * `"hello—world"` 作为一个 token + * `"don't"` 作为一个 token +* 因此:EN 断点仅存在于**词与词之间**,不存在“标点后断点”概念。 +* Shift / Accum / Self / EmotionWords 的匹配在 token.text 上执行(可大小写归一),允许被词内标点包裹(如 `"but,"` 仍可视为命中 `"but"`)。 + +#### 2.3.1A-1(实现口径补充)EN 关键词命中规则:**全词等值匹配,不做 substring**(必须) + +为避免 `"rebuttal"` 被 substring 误命中 `"but"` 等问题,EN 命中判断固定为: + +* 对 `token.text` 执行:`lowercase → strip 两端常见标点 → 全词等值比较` +* **不允许** substring / contains 式命中。 +* “常见标点”建议配置为(可按需扩展,但全端一致): + `, . ! ? : ; " ' … — – ( ) [ ] { }` + +> 说明:此规则只影响“词库命中判定”,不改变 tokenization(仍为极简派)。 + +### 2.3.2 TC 索引约定 + +* TC tokens 为 grapheme clusters。 +* 断点 `pos` 表示:在第 `pos` 个 grapheme **之前** 断开。 + +**例**:`"我好累😮‍💨"` + +* tokens = `[我, 好, 累, 😮‍💨]`(😮‍💨 为一个 cluster) +* breaks=[3] → `"我好累"` / `"😮‍💨"` + +### 2.3.3(可选)原始空白保留策略 + +若产品需要保留输入中的多空格/换行(一般不建议),必须把空白映射作为单独结构: + +* `rawSeparators[i]` 表示 `tokens[i]` 与 `tokens[i+1]` 之间的原始分隔符。 +* 重组时按 `rawSeparators` 拼接。 + +否则(推荐默认):对输入先做 `normalizeWhitespace`(折叠连续空白为 1 个空格,去首尾空白),并在 meta 里打点 `hadMultiWhitespace`。 + +--- + +## 3. 宽度测量(必须可实现) + +### 3.1 App(建议精确测量) + +实现者需提供一个可插拔的测量函数: + +```ts +measureWidth(text: string, fontSpec): number +``` + +* RN iOS 推荐用原生测量(或 TextLayout 结果) +* 重要:测量时应使用与实际渲染一致的 fontSpec + +### 3.2 Widget(建议估算/常量) + +Widget 场景不强制精确测量,推荐: + +* per widgetSize 固定 availableWidth(扣 padding) +* EN:按词数/字符数限制候选搜索范围 +* TC:候选断点上限更小 + +> 注意:Widget 常量需在 iOS 大版本或版式变更时校准。 + +--- + +## 3.3(新增)测量缓存与宽度不可用降级(强烈建议) + +### 3.3.1 缓存 Key 规范 + +为保证性能与确定性,建议至少两级缓存: + +1. **字符串测量缓存**:`(text, fontSpecKey, contextProfile) -> width` +2. **段落切片缓存**:`(start, end) -> width`(对 DP 重复切片测量非常关键) + +`fontSpecKey` 必须规范化:例如 `fontFamily|fontWeight|fontSize`。 + +### 3.3.2 宽度不可用(或测量失败)降级 + +若出现以下任一情况: + +* `measureWidth` 不可用 +* `measureWidth` 抛错/返回 NaN +* Widget 选择不测量 + +则进入 `approx mode`: + +* 把 width 近似替换为:EN 用 `wordCount`,TC 用 `charCount` +* 强制使用 Widget Beam 搜索策略与更小候选规模 +* 打点:`WIDTH_UNKNOWN` + +--- + +## 4. 规则体系(Rule System) + +规则按优先级分四层: + +1. **硬约束(Hard Constraints)**:不满足直接淘汰 +2. **情绪规则(Emotion Rules)**:强惩罚/强奖励 +3. **节奏与语义(Rhythm/Semantic Heuristics)**:中等权重 +4. **视觉均衡(Visual Balance)**:用于多行整体观感 + +--- + +## 5. 硬约束(Hard Constraints) + +> 任何候选 layout / line 违反即淘汰 + +### H1 不超宽 + +* 对每一行:`line.width <= availableWidth` + +### H2 不非法拆分 + +* EN:断点只能在词间 +* TC:断点只能在 grapheme cluster 边界(不得拆 ZWJ emoji 组合) + +### H3 不空行 + +* `trim(line.text) != ''` + +### H4 行内容下限(可降级) + +* TC:去空格 charCount >= `minCharsPerLineTC`(默认 2) +* EN:tokenCount >= 1(词) + +### H5 最大行数 + +* layout.lines.length <= maxLines + +--- + +## 5.1(新增)必须禁止的断点(推荐硬约束,提高稳定性) + +### H6 禁止“行首标点”(TC 强烈建议) + +* 若一个断点导致 **下一行第一个 token 为标点**(属于 `tcPunctuations`),则该断点不可用。 + +### H7 受保护短语内断点可直接剔除(可选优化) + +* 对 `constraints.protectedPhrases`:可先计算其 token span(start/end),并在候选断点生成阶段直接过滤 span 内断点。 +* 若不做剔除,也必须在评分中施加强惩罚(见 10.2A)。 + +--- + +## 6. Tokenize 与断点候选生成 + +### 6.1 英文 EN + +**Tokenize** + +* 以空格为分隔 +* 标点贴附在词尾(实现可简化) +* 建议先 normalizeWhitespace(见 2.3.3) + +**Breakpoints** + +* 在每个“词边界”(词与词之间)产生 breakpoint +* kind=SPACE,priority=基础值 + +**Widow 辅助信息** + +* 标记短词:`len(word) <= widowMaxLen`(默认 3) + +### 6.2 中文 TC + +**Tokenize** + +* 按 grapheme cluster 切分(实现可用现成库或平台 API;无库时至少保证不拆 surrogate pair + 常见 ZWJ) + +**Breakpoints 优先级(高→低)** + +1. 标点后(kind=PUNCT,priority 高) +2. 空格后(kind=SPACE,priority 中) +3. 均衡补齐断点(kind=BALANCE,priority 低) + +**均衡补齐(BALANCE)** + +* 目标:即便无标点,也能在“接近理想位置”的附近有断点 + +* 计算理想切分点: + + * `targetLines = min(maxLines, estimateNaturalLines())` + * `idealCharsPerLine ≈ totalChars / targetLines` + +* 对每个理想切分点 i:在 `[idealPos - range, idealPos + range]` 生成少量断点 + +* range 建议:3~6 个 grapheme + +**候选规模上限(必须)** + +* TC 候选断点上限:`tcMaxCandidateBreaks`(建议 40~80,Widget 可更低) +* 截断策略:按 priority(标点>空格>补齐) + 距离理想位置近优先 + +### 6.2A(裁决补充)BALANCE 断点与情绪短语冲突处理 + +* BALANCE 断点**允许生成**在 emotionPhrase / protectedPhrases 的 span 内。 +* 但这类断点若导致短语被拆分,会在评分阶段触发 **10.2A 的极大惩罚**,从而被自然淘汰。 +* 目的:保持候选生成简单、可控,把“是否可用”统一交给评分与搜索层决定(仍保持确定性)。 + +--- + +## 6.3(新增)TC Grapheme Cluster 实现要求(跨端一致性) + +为避免 iOS/Android/JS 行为不一致,必须明确实现级别: + +### 推荐实现(优先) + +* 使用平台级 grapheme segmentation(如 ICU / 系统分词器 / JS 端可用 `Intl.Segmenter` 时优先)。 + +### 最低可用实现(无库兜底) + +至少保证以下组合不被拆开: + +* surrogate pair(代理对) +* ZWJ sequence(如家庭 emoji) +* variation selector(VS16 等) +* skin tone modifier(肤色修饰符) +* regional indicator flags(国旗) + +### 必须的回归样例(至少覆盖) + +* `👨‍👩‍👧‍👦`、`🇸🇬`、`👍🏽`、`😮‍💨`、`é`(组合字符) + +--- + +## 6.4(新增)候选断点的去重、排序与过滤(必须确定性) + +* 去重:同一 `pos` 出现多个 breakpoint 时,保留 priority 更高者(或合并为最高 kind)。 +* 排序:最终 breakpoints 必须按 `pos` 升序排列。 +* 过滤:应用 `constraints.forbiddenBreakRanges` 与(可选)protected phrase span 过滤。 + +--- + +## 7. 情绪规范 → 系统规则映射(参数化实现) + +> 这一节将“内容规范”落到系统可执行的参数与规则。 + +### 7.1 关键词库(配置项) + +**情绪短语(强保护)** + +* `emotionPhrasesTC[]` +* `emotionPhrasesEN[]` + +**情绪转折词(Shift)** + +* TC:但 / 可是 / 然而 / 却 / 只是 / 偏偏 +* EN:but / yet / so(and 为轻量) + +**情绪累积词(Accum)** + +* TC:已经 / 一直 / 曾经 / 终于 / 还是 / 到现在 +* EN:already / still / even / just / really + +**自我指向词(Self)** + +* TC:你 / 我 / 自己 / 我们 / 别人 +* EN:you / yourself / me / we + +**情绪词(EmotionWords,可选)** + +* TC:累 / 痛 / 怕 / 孤单 / 委屈 / 撑 / 崩溃 / 放弃 +* EN:tired / afraid / lonely / hurt / overwhelmed / give up + +> 备注:词库不需要完美,v1.2 以“少而准”为原则,通过打点迭代。 + +### 7.2 系统行为映射(实现点) + +1. **情绪短语保护** + + * 若任何情绪短语被断点拆到不同的行 → layout 加强惩罚(或直接淘汰) + +2. **Shift/Accum/Self 断点奖励** + + * 若断点在这些词附近(词前或词后,按语言定义) → 给该行/该断点奖励 + +3. **情绪落点强化** + + * 若一行以情绪词结尾(或靠近行尾) → 奖励 + * 若情绪词被埋在长行中间 → 惩罚 + +#### 7.2B(实现口径补充)“靠近行尾”的确定性定义(必须) + +为避免实现者自行发挥,“靠近行尾”固定为: + +* EN:情绪词位于该行 **最后 1 个词**(即行尾词)时视为命中“靠近行尾” +* TC:情绪词位于该行 **最后 2 个 grapheme** 范围内时视为命中“靠近行尾” + +> 注:此定义只用于 EmotionWord 强化(奖励/惩罚),不影响 tokenization 与断点生成。 + +### 7.2A(裁决补充)EN 断点奖励的“行首/行尾感知”定义(必须) + +对 EN 的 Shift / Accum / Self 奖励采用 **行首/行尾感知**: + +* 若某断点 `pos` 使得: + + * **新行的第一个词**命中对应词库(Shift/Accum/Self),则对该断点给予奖励(推荐作为主奖励路径)。 + * 或 **上一行的最后一个词**命中对应词库,则也可给予奖励(可与前者相同或略低,但必须固定实现;若未单独配置,默认与前者相同以简化)。 +* 命中判断在 token.text 上做确定性匹配(可先做小写化与两端去常见标点)。 + +--- + +## 8. 行长度与节奏参数(建议默认值) + +### 8.1 中文(TC) + +* `minCharsPerLineTC`: 4(弱规则;硬约束下限仍为 2) +* `idealCharsPerLineTC`: 8~12(用于评分) +* `maxCharsPerLineTC`: 16(强惩罚;如超过可视为必须换行的压力项) + +### 8.2 英文(EN) + +* `minWordsPerLineEN`: 2(弱规则;硬约束下限仍为 1) +* `idealWordsPerLineEN`: 3~6 +* `maxWordsPerLineEN`: 8(强惩罚) + +> 注:这些不是“硬塞阈值”,而是用于评分与候选裁剪的偏好。 + +### 8.3(裁决补充)理想长度采用“宽度派”(必须) + +为提升视觉一致性,idealLen 采用 **宽度派**推导: + +* 在 width 可用时: + + * 以 `idealWidth = availableWidth * idealWidthRatio`(建议 0.85~0.95,写入配置) + * 对 EN:`idealWordsPerLineEN` 不作为固定常量,而作为弱上界/弱先验;评分中的 ideal 以 **width-based** 的 line.width 与 idealWidth 的距离为主。 + * 对 TC:`idealCharsPerLineTC` 同理,主要以 **width-based** idealWidth 做评分;chars 仅作辅助项(例如 Widget 或 width unknown 时)。 +* 在 width unknown / approx mode 时: + + * 回退到 chars/words 的 ideal 区间(8~12 / 3~6)。 + +#### 8.3A(实现口径补充)idealWidthRatio 默认值(必须写死到配置) + +为保证首版跨端一致,默认值裁决为: + +* `idealWidthRatio.APP = 0.90` +* `idealWidthRatio.WIDGET = 0.95` + +> 后续若做 A/B 或灰度调整,请通过 configVersion 管理并打点回溯。 + +--- + +## 9. 搜索与组合(可直接实现) + +多行换行的核心是“断点组合搜索”。要求: + +* 输出最优 layout +* 复杂度可控 +* 完全确定性 + +### 9.1 推荐:DP + TopK(App) + +**状态定义** + +* `dp[pos][linesUsed] = TopK layouts ending at token boundary pos` +* pos 是 token 边界索引(0..N) + +**转移** + +* 从 (pos, linesUsed) 选择下一个断点 `nextPos` 形成一行 [pos..nextPos) +* 计算 lineText 与 lineWidth +* 若违反硬约束,跳过 +* 计算增量评分(见第 10 节) +* 插入 dp[nextPos][linesUsed+1] 的 TopK + +**TopK 维护(确定性)** + +* K 建议 10 +* 排序:score desc;tie-break 使用固定 keys(见第 11 节) +* 插入时去重(同 breaks 序列只保留最高分) + +**结束条件** + +* 在 `pos=N`(结束边界)处,从 `dp[N][<=maxLines]` 选最优 +* lineMode=FIXED 时优先选 `dp[N][==maxLines]`,否则降级 + +### 9.2 推荐:Beam Search(Widget) + +* 每一行扩展时只保留 TopK partial layouts(K 建议 5) +* 候选断点也更少(更强裁剪) + +**Beam 过程(概念)** + +* 初始 beams = [{pos=0, breaks=[], score=0}] + +* repeat for lineIndex in 1..maxLines: + + * 对每个 beam,从 pos 扩展到若干 nextPos,生成新 beams + * 过滤硬约束 + * 评分 + * 全局保留 TopK beams + +* 结束时从 beams 中挑 pos==N 最优,否则进入溢出/兜底 + +--- + +## 9.3(新增)复杂度与性能预算(建议写进实现约束) + +为保证线上性能稳定,建议默认上限: + +* App:`tcMaxCandidateBreaks <= 80`,`TopK=10`,`maxLines<=3`(或 4) +* Widget:Beam `K<=5`,每步扩展断点数 `M<=12` + +当输入超长时(例如 TC>60 grapheme 或 EN>30 words),建议触发候选裁剪或直接走溢出策略并打点 `TOO_LONG`。 + +--- + +## 10. 评分模型(Scoring Model,建议实现顺序) + +> 评分用于在“多个合法断点组合”中选最优。权重固定以保证稳定性。 + +### 10.1 强烈建议实现顺序 + +1. 情绪短语拆分惩罚(最高优先) +2. 行超长惩罚 + 理想长度奖励 +3. widow 惩罚(EN)/ 单字行惩罚(TC) +4. 标点断点奖励(TC) +5. Shift/Accum/Self 断点奖励 +6. 多行视觉均衡(尾行过短惩罚) + +#### 10.1A(实现口径补充)评分实现顺序不可重排(必须) + +* 所有评分项必须按 10.1 的顺序计算并记录到 breakdown。 +* 后续项不得“覆盖”前序裁决结果(例如:已触发 EmotionPhrase 拆分强惩罚后,不允许因为其他奖励而在实现层面跳过/抵消该惩罚的记录)。 +* 允许在数学意义上出现“总分被奖励抬高”,但 **必须保留每一项的确定性记录**,以便 debug 与治理。 + +### 10.2 评分项清单(可直接落地) + +**A. 情绪短语保护(EmotionPhrase)** + +* 若拆分任意 emotionPhrase:`score -= P_EMOTION_SPLIT`(非常大) +* 若被 constraints.protectedPhrases 拆分:`score -= P_PROTECTED_SPLIT`(非常大) + +#### 10.2A(实现口径补充)Phrase 匹配必须为“连续 token 完全匹配”(必须) + +* emotionPhrase / protectedPhrases 的匹配方式固定为: + **连续 token 的完全匹配**(EN=连续词序列,TC=连续 grapheme 序列),不允许跳 token、不允许跨断点、不允许模糊匹配。 +* 若采用预计算 span:span 的 start/end 必须与 token 索引体系一致(见 2.3)。 + +### 10.2A(裁决补充)emotionPhrases 与 protectedPhrases 的冲突优先级 + +当 emotionPhrase 与 protectedPhrase 发生竞争(无法同时满足)时: + +* 两者视为**同级强保护**,在评分上同量级惩罚; +* **tie-break/决策时以“更长者优先”**: + + * 若某 layout 保住了更长短语(例如 `"真的好累"`)而拆了更短短语(例如 `"好累"`),在同等可行性下应更倾向前者。 +* 实现建议:在拆分惩罚触发时,额外记录被拆分短语的长度(token span 长度),用于 tieKey 或额外惩罚的细分(必须确定性)。 + +**B. Shift/Accum/Self(词附近断点奖励)** + +* 断点在 shiftWord 行首/行尾命中:`score += R_SHIFT_BREAK` +* 断点在 accumWord 行首/行尾命中:`score += R_ACCUM_BREAK` +* 断点在 selfWord 行首/行尾命中:`score += R_SELF_BREAK` + +**C. 行长度(Length)** + +* `score += f_ideal(lineLen, idealLen)`(越接近越好) +* 若 lineLen > maxLen:`score -= P_OVER_MAXLEN * (lineLen - maxLen)` +* 若 lineLen < minPreferred:`score -= P_TOO_SHORT * (minPreferred - lineLen)` + +> f_ideal 可用简单的:`-abs(lineLen - idealLen)` + +**D. 标点断点(TC)** + +* 若断点位于 PUNCT 后:`score += R_PUNCT_BREAK` +* 若行首为标点:淘汰(建议见 H6)或极大惩罚 + +**E. Widow(EN)与尾行过短(All)** + +* EN:最后一行只有 1 个词:`score -= P_WIDOW_LINE` +* EN:最后一行只有 1 个短词:`score -= P_WIDOW_WORD` +* All:最后一行宽度 < 某阈值(例如 idealWidth*0.5):`score -= P_SHORT_LASTLINE` + +**F. TC 助词孤立(TC)** + +* 行首/行尾为助词:`score -= P_PARTICLE_ISO` +* 注:语尾语助词(啊/喔/呢/啦)可通过例外白名单降惩罚 + +### 10.2F(裁决补充)TC 语尾语助词白名单:惩罚减半 + +* 对白名单中的语尾语助词(如:啊/喔/呢/啦 等): + + * 若触发 PARTICLE_ISO(行首/行尾助词),其惩罚使用: + **`P_PARTICLE_ISO / 2`** +* 非白名单助词仍使用完整 `P_PARTICLE_ISO`。 +* 白名单列表必须写入配置并全端一致。 + +### 10.2G(裁决补充)EmotionWord 与 Accum 奖励关系(必须) + +为避免奖励叠加导致评分失真,奖励关系固定为: + +* **EmotionWord > Accum**: + + * 若同一行(或同一断点)同时满足 EmotionWord 强化与 Accum 断点奖励: + + * EmotionWord 相关奖励保持原值 + * Accum 相关奖励按 **0.5 倍**计算(即“后者减半”) +* 该规则必须确定性实现(例如先计算 EmotionWord 命中,再对 Accum 奖励做折减)。 + +### 10.3 建议的默认权重(仅供实现起步) + +> 你可以先用相对大小,不必精确数值。 + +* P_EMOTION_SPLIT:10000 +* P_PROTECTED_SPLIT:10000 +* P_WIDOW_WORD:800 +* P_WIDOW_LINE:500 +* P_SHORT_LASTLINE:300 +* P_PARTICLE_ISO:200 +* R_PUNCT_BREAK:80 +* R_SHIFT_BREAK:60 +* R_ACCUM_BREAK:40 +* R_SELF_BREAK:20 +* P_OVER_MAXLEN:30 +* P_TOO_SHORT:10 + +--- + +## 10.4(新增)评分可解释结构(Debug/治理必备) + +为便于线上治理与调参,建议统一输出 score breakdown: + +```ts +ScoreBreakdown = { + total: number, + terms: Array<{ key: string; delta: number; detail?: any }> +} +``` + +* `key` 建议枚举:`EMOTION_SPLIT | PROTECTED_SPLIT | OVER_MAXLEN | IDEAL_LEN | TOO_SHORT | PUNCT_BREAK | SHIFT_BREAK | ACCUM_BREAK | SELF_BREAK | WIDOW_LINE | WIDOW_WORD | SHORT_LASTLINE | PARTICLE_ISO` +* debug=true 时可只输出贡献最大的前 3 项。 + +### 10.4A(裁决补充)Debug Top-3 输出排序:按规则优先级 + +debug=true 时输出的“关键项”排序采用**规则优先级**而非 |delta|: + +* 按 10.1 的实现顺序(优先级)输出 +* 若同优先级内有多项,可再按 |delta| 或出现顺序稳定排序(必须确定性) + +--- + +## 11. Tie-break(必须固定,确保确定性) + +当 score 相同或非常接近时,按顺序比较: + +1. emotionSplit=false 优先 +2. overflowed=false 优先 +3. lastLineWidth 更大优先(避免短尾行) +4. 行宽分布更均匀优先(可用 maxWidth-minWidth 更小) +5. 断点更接近理想切分点优先(距离之和更小) +6. 断点序列字典序更靠前优先(例如 firstBreak 更小) + +> 实现建议:为 Layout 计算一个 `tieKey` 数组,逐项比较。 + +### 11.0A(裁决补充)breaks[] 字典序比较定义(必须) + +断点序列字典序比较规则固定为: + +* 从 `index=0` 起逐项比较 `breaks[i]`: + + * 首个不同元素更小者视为更小 +* 若公共前缀完全相同,则: + + * **更短的数组**视为更小(短数组在公共前缀相等时优先) + +--- + +## 11.1(新增)分数精度与浮点处理(跨端一致性强烈建议) + +* 建议所有评分项都用 **整数**,避免浮点误差。 +* 若必须使用浮点,必须定义 `EPS`:例如 `EPS=1e-6`。 +* “非常接近”的定义:`abs(a-b) <= EPS`。 + +--- + +## 12. 溢出与兜底(必须定义清楚) + +### 12.1 overflowMode + +* `ELLIPSIS`:最后一行加省略号(Widget 推荐默认) +* `CLIP`:截断到 maxLines +* `SYSTEM_DEFAULT`:不插入换行,交给系统 + +### 12.2 何时算 overflow + +* 搜索无法在 <=maxLines 覆盖到文本结束边界 N +* 或 lineMode=FIXED 需要刚好 maxLines,但无法到 N + +### 12.3 ELLIPSIS 的实现约束 + +* 仅在最后一行处理 +* 必须再次检查 H1(加省略号后是否超宽) +* 若超宽:优先从最后一行尾部移除 token 再加省略号 +* 尽量避免把 emotionPhrase 截断在中间: + + * 若会截断,可选择整段前移或整段省略(以评分决定) + +### 12.4 无解兜底 + +降级链: + +1. 保证 H1/H2/H3 +2. 将 H4/助词/widow 从强规则降为弱惩罚 +3. 若仍无解:SYSTEM_DEFAULT + +### 12.4A(裁决补充)SYSTEM_DEFAULT 的返回语义(必须) + +当选择 `SYSTEM_DEFAULT` 时: + +* wrapText **仍返回 lines / wrappedText(可为未换行的单行或原始文本)** +* 并且 **仅在 meta 中标记**:`fallback_type = SYSTEM_DEFAULT` +* 外部组件(UI 层)依据该 meta 决定是否完全交给系统排版(例如不插入 `\n`,或忽略 lines 渲染)。 + +> 关键点:算法层不擅自改变 UI 行为,只提供明确标记,保证可治理与跨端一致。 + +--- + +## 12.5(新增)ELLIPSIS 细则(必须明确字符与清理规则) + +* 省略号字符统一:`ellipsisToken = "…"`(推荐单字符)或 `"..."`(三字符),必须写入配置并全端一致。 +* EN 截断回退单位:**整词**;TC 回退单位:**grapheme**。 +* 清理规则: + + * 不允许输出 `" …"`(空格+省略号) + * 不允许输出 `",…"` 或 `"。…"`(标点+省略号)时可按配置决定是否移除末尾标点再加省略号 +* 加省略号必须重新测量宽度,确保不超宽。 + +--- + +## 13. 受控人工约束(可选,但建议实现) + +### 13.1 protectedPhrases + +* 行为:拆分该短语 → 视同 emotionPhrase 拆分(强惩罚) + +### 13.2 forbiddenBreakRanges + +* 行为:断点 pos 落在 [start,end] 内 → 候选断点直接剔除 + +> 注意:start/end 的单位应与 token 索引一致(EN=词边界;TC=字符簇边界)。 + +--- + +## 14. Debug Meta 与打点(强烈建议) + +### 14.1 debug meta(可选输出) + +* chosen breaks +* score breakdown(可只输出前 3 个最关键项;排序见 10.4A) +* fallback/overflow 原因 + +### 14.2 线上打点建议 + +* fallback_type:NONE / RELAX_RULES / SYSTEM_DEFAULT +* overflow_type:NONE / ELLIPSIS / CLIP +* reason:NO_CANDIDATE / WIDTH_UNKNOWN / WIDOW / PARTICLE / TOO_LONG +* lang/context/maxLines/widgetSize + +--- + +## 15. 实现步骤(建议落地路线) + +1. EN tokenize + SPACE breakpoints + 2 行先跑通(EN 标点按极简派;关键词命中按 2.3.1A-1) +2. TC tokenize(grapheme)+ PUNCT breakpoints + 2 行跑通 +3. 引入 emotionPhrases 拆分惩罚(phrase 匹配按 10.2A) +4. 引入 DP + TopK(多行) +5. 引入 overflowMode(Widget 先用 ELLIPSIS) +6. 引入 constraints + 打点 +7. 调权重与扩词库(基于数据) + +--- + +## 16. 附录:最小配置清单(实现时至少要有) + +* emotionPhrasesTC / emotionPhrasesEN +* tcPunctuations +* tcParticles(含白名单子集或单独 tcParticleWhitelist) +* widowMaxLen +* tcMaxCandidateBreaks +* minCharsPerLineTC +* widgetProfiles(Widget:availableWidth/maxLines/overflowMode/beamK) +* ellipsisToken(新增) +* EPS(若使用浮点;新增) +* idealWidthRatio(新增,宽度派必需;默认值见 8.3A) + +--- + +## 16.1(新增)金标测试用例与回归策略(强烈建议) + +为保证“确定性 + 可治理”,建议维护一份 Golden Cases: + +* 每种语言至少 20 个样例(短、长、含标点、无标点、含 emoji、含 protectedPhrases、含 shift/accum/self、极窄宽度) +* 每个样例包含: + + * input text + * lang/context + * availableWidth/maxLines/fontSpec(或 widget profile) + * expected lines[] + +再加 3 类性质测试(property tests): + +1. **同输入多次调用输出一致** +2. **availableWidth 变小不会让任一行变得更宽**(近似单调性检查) +3. **maxLines 增加时不应更差**(至少不从可解变 overflow) + +--- + +## 16.2(新增)配置版本治理(建议) + +* `configVersion` 应代表“可灰度的规则包版本”。 +* meta 与线上打点中必须带上 `configVersion`,以便回溯与对比实验。 + +--- + +## 17. 结语 + +这套算法的目标不是理解情绪,而是: + +> 在规模化系统中,持续做出「像人一样停顿」的选择。 + +只要:确定性 + 可配置 + 可治理,你就能持续把“情绪表达”变成产品护城河。