2 Commits

Author SHA1 Message Date
吕新雨
ee2d9f44ea 更新换行算法和APP-PUSH 2026-02-10 11:39:33 +08:00
吕新雨
f03d36b5e9 增加定时服务的健康检测 2026-02-09 15:39:46 +08:00
111 changed files with 10197 additions and 244 deletions

View File

@@ -1,4 +1,4 @@
当前有一个很大的 spec.md大需求规范需要按业务逻辑拆分成多个子模块规范。 当前有一个很大的 spec.md大需求规范需要按业务逻辑合理拆分成多个子模块规范。
请按以下规则拆分: 请按以下规则拆分:

View File

@@ -56,6 +56,9 @@ jobs:
# 健康检查路径(未配置则默认 /health如果你没有 health 接口,可改为 /docs 或 / # 健康检查路径(未配置则默认 /health如果你没有 health 接口,可改为 /docs 或 /
HEALTHCHECK_PATH: ${{ vars.HEALTHCHECK_PATH }} HEALTHCHECK_PATH: ${{ vars.HEALTHCHECK_PATH }}
# 定时服务健康检查路径(检查 Redis/Worker/Beat未配置则默认 /v1/push/scheduler/health
SCHEDULER_HEALTHCHECK_PATH: ${{ vars.SCHEDULER_HEALTHCHECK_PATH }}
# 可选:远端 env 文件路径(例如 /opt/mindfulness-server/.env.prod存在则 docker run --env-file # 可选:远端 env 文件路径(例如 /opt/mindfulness-server/.env.prod存在则 docker run --env-file
REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }} REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }}
@@ -258,11 +261,12 @@ jobs:
GREEN_PORT="${GREEN_PORT:-8002}" GREEN_PORT="${GREEN_PORT:-8002}"
CONTAINER_PORT="${CONTAINER_PORT:-8000}" CONTAINER_PORT="${CONTAINER_PORT:-8000}"
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}" HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}"
SCHEDULER_HEALTHCHECK_PATH="${SCHEDULER_HEALTHCHECK_PATH:-/v1/push/scheduler/health}"
echo "准备部署:${DOCKER_IMAGE}:${DEPLOY_TAG} -> ${DEPLOY_ENV} (${SSH_USER}@${SSH_HOST}:${SSH_PORT})" echo "准备部署:${DOCKER_IMAGE}:${DEPLOY_TAG} -> ${DEPLOY_ENV} (${SSH_USER}@${SSH_HOST}:${SSH_PORT})"
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "${SSH_USER}@${SSH_HOST}" bash -s -- \ ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "${SSH_USER}@${SSH_HOST}" bash -s -- \
"${DOCKER_IMAGE}" "${DEPLOY_TAG}" "${NGINX_UPSTREAM_FILE}" "${NGINX_UPSTREAM_NAME}" "${BLUE_PORT}" "${GREEN_PORT}" "${CONTAINER_PORT}" "${HEALTHCHECK_PATH}" "${REMOTE_ENV_FILE:-}" <<'REMOTE' "${DOCKER_IMAGE}" "${DEPLOY_TAG}" "${NGINX_UPSTREAM_FILE}" "${NGINX_UPSTREAM_NAME}" "${BLUE_PORT}" "${GREEN_PORT}" "${CONTAINER_PORT}" "${HEALTHCHECK_PATH}" "${SCHEDULER_HEALTHCHECK_PATH}" "${REMOTE_ENV_FILE:-}" <<'REMOTE'
set -euo pipefail set -euo pipefail
IMAGE="$1" IMAGE="$1"
@@ -273,7 +277,8 @@ jobs:
GREEN_PORT="$6" GREEN_PORT="$6"
CONTAINER_PORT="$7" CONTAINER_PORT="$7"
HEALTHCHECK_PATH="$8" HEALTHCHECK_PATH="$8"
REMOTE_ENV_FILE="$9" SCHEDULER_HEALTHCHECK_PATH="$9"
REMOTE_ENV_FILE="${10}"
APP_DIR="/opt/mindfulness-server" APP_DIR="/opt/mindfulness-server"
ACTIVE_FILE="${APP_DIR}/active_color" ACTIVE_FILE="${APP_DIR}/active_color"
@@ -307,9 +312,18 @@ jobs:
# 拉取镜像 # 拉取镜像
${SUDO} docker pull "${IMAGE}:${TAG}" ${SUDO} docker pull "${IMAGE}:${TAG}"
# 启动新颜色容器 # 启动新颜色容器API + Worker + Beat
API_NAME="mindfulness-server-api-${NEW_COLOR}"
WORKER_NAME="mindfulness-server-worker-${NEW_COLOR}"
BEAT_NAME="mindfulness-server-beat-${NEW_COLOR}"
# 兼容旧命名:之前可能只有一个 mindfulness-server-blue/green
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/dev/null 2>&1 || true ${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/dev/null 2>&1 || true
${SUDO} docker rm -f "${API_NAME}" >/dev/null 2>&1 || true
${SUDO} docker rm -f "${WORKER_NAME}" >/dev/null 2>&1 || true
${SUDO} docker rm -f "${BEAT_NAME}" >/dev/null 2>&1 || true
ENV_FILE_ARGS=() ENV_FILE_ARGS=()
if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then
ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}") ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}")
@@ -318,11 +332,29 @@ jobs:
echo "提示REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file" echo "提示REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file"
fi fi
# API对外暴露端口仅该容器参与蓝绿切流
${SUDO} docker run -d \ ${SUDO} docker run -d \
--name "mindfulness-server-${NEW_COLOR}" \ --name "${API_NAME}" \
--restart=always \ --restart=always \
-p "${NEW_PORT}:${CONTAINER_PORT}" \ -p "${NEW_PORT}:${CONTAINER_PORT}" \
"${ENV_FILE_ARGS[@]}" \ "${ENV_FILE_ARGS[@]}" \
-e START_API=1 -e START_WORKER=0 -e START_BEAT=0 \
"${IMAGE}:${TAG}"
# Worker处理异步/ETA 任务,不暴露端口)
${SUDO} docker run -d \
--name "${WORKER_NAME}" \
--restart=always \
"${ENV_FILE_ARGS[@]}" \
-e START_API=0 -e START_WORKER=1 -e START_BEAT=0 \
"${IMAGE}:${TAG}"
# Beat定时调度不暴露端口
${SUDO} docker run -d \
--name "${BEAT_NAME}" \
--restart=always \
"${ENV_FILE_ARGS[@]}" \
-e START_API=0 -e START_WORKER=0 -e START_BEAT=1 \
"${IMAGE}:${TAG}" "${IMAGE}:${TAG}"
# 健康检查 # 健康检查
@@ -350,8 +382,37 @@ jobs:
if [[ "$i" -eq 30 ]]; then if [[ "$i" -eq 30 ]]; then
echo "健康检查失败:新版本未就绪,回滚并退出" echo "健康检查失败:新版本未就绪,回滚并退出"
${SUDO} docker logs --tail 200 "mindfulness-server-${NEW_COLOR}" || true ${SUDO} docker logs --tail 200 "${API_NAME}" || true
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true ${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
exit 1
fi
sleep 2
done
# 定时服务健康检查(确保 Redis/Worker/Beat 都 OK避免“接口正常但定时任务没跑”
if [[ "${SCHEDULER_HEALTHCHECK_PATH}" != /* ]]; then
SCHEDULER_HEALTHCHECK_PATH="/${SCHEDULER_HEALTHCHECK_PATH}"
fi
SCHED_URL="http://127.0.0.1:${NEW_PORT}${SCHEDULER_HEALTHCHECK_PATH}"
echo "定时服务健康检查:${SCHED_URL}"
# Beat 心跳是按分钟刷新,这里最多等 90 秒45*2s
for i in $(seq 1 45); do
RES="$(curl -fsS "${SCHED_URL}" 2>/dev/null || true)"
if [[ -n "${RES}" ]] \
&& echo "${RES}" | grep -q '"redis":{"ok":true' \
&& echo "${RES}" | grep -q '"worker":{"ok":true' \
&& echo "${RES}" | grep -q '"beat":{"ok":true' ; then
echo "定时服务健康检查通过:${RES}"
break
fi
if [[ "$i" -eq 45 ]]; then
echo "定时服务健康检查失败:${RES}"
echo "Worker/Beat 日志(各 120 行):"
${SUDO} docker logs --tail 120 "${WORKER_NAME}" || true
${SUDO} docker logs --tail 120 "${BEAT_NAME}" || true
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
exit 1 exit 1
fi fi
sleep 2 sleep 2
@@ -360,24 +421,24 @@ jobs:
# 切换 Nginx upstream在同一个 conf 文件中通过 backup 做主备切换) # 切换 Nginx upstream在同一个 conf 文件中通过 backup 做主备切换)
if [[ ! -f "${UPSTREAM_FILE}" ]]; then if [[ ! -f "${UPSTREAM_FILE}" ]]; then
echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}" echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}"
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true ${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
exit 1 exit 1
fi fi
if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then
echo "在 ${UPSTREAM_FILE} 中未找到 upstream${UPSTREAM_NAME}" echo "在 ${UPSTREAM_FILE} 中未找到 upstream${UPSTREAM_NAME}"
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true ${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
exit 1 exit 1
fi fi
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${BLUE_PORT}" "${UPSTREAM_FILE}"; then if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${BLUE_PORT}" "${UPSTREAM_FILE}"; then
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${BLUE_PORT}(请先按参考配置写入 upstream" echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${BLUE_PORT}(请先按参考配置写入 upstream"
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true ${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
exit 1 exit 1
fi fi
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${GREEN_PORT}" "${UPSTREAM_FILE}"; then if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${GREEN_PORT}" "${UPSTREAM_FILE}"; then
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${GREEN_PORT}(请先按参考配置写入 upstream" echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${GREEN_PORT}(请先按参考配置写入 upstream"
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true ${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
exit 1 exit 1
fi fi
@@ -416,7 +477,7 @@ jobs:
echo "Nginx 配置校验失败,回滚 upstream 配置并退出" echo "Nginx 配置校验失败,回滚 upstream 配置并退出"
${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true ${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true
${SUDO} nginx -t && ${SUDO} nginx -s reload || true ${SUDO} nginx -t && ${SUDO} nginx -s reload || true
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true ${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
exit 1 exit 1
fi fi
@@ -424,6 +485,11 @@ jobs:
echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null
# 下线旧容器(切流后再停旧的) # 下线旧容器(切流后再停旧的)
OLD_API_NAME="mindfulness-server-api-${OLD_COLOR}"
OLD_WORKER_NAME="mindfulness-server-worker-${OLD_COLOR}"
OLD_BEAT_NAME="mindfulness-server-beat-${OLD_COLOR}"
${SUDO} docker rm -f "${OLD_API_NAME}" "${OLD_WORKER_NAME}" "${OLD_BEAT_NAME}" >/dev/null 2>&1 || true
# 兼容旧命名
${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true ${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true
echo "部署完成:${NEW_COLOR} 已上线" echo "部署完成:${NEW_COLOR} 已上线"

View File

@@ -11,11 +11,14 @@ export default ({ config }: ConfigContext): ExpoConfig => {
const projectId = const projectId =
process.env.EXPO_PUBLIC_EAS_PROJECT_ID || process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
// 兼容部分 CI/EAS 注入的变量名 // 兼容部分 CI/EAS 注入的变量名
process.env.EAS_PROJECT_ID || process.env.EAS_PROJECT_ID;
undefined;
return { return {
...config, ...config,
// ExpoConfig 的类型要求 name 必填,避免 `...config` 的可选类型导致 tsc 报错
name: config.name ?? 'client',
// slug 在绝大多数场景也建议固定为非空字符串(保持与 app.json 一致)
slug: config.slug ?? 'client',
extra: { extra: {
...(config.extra ?? {}), ...(config.extra ?? {}),
eas: { eas: {

View File

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

View File

@@ -1,5 +1,15 @@
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react'; 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 { useTranslation } from 'react-i18next';
import { useFocusEffect } from 'expo-router'; import { useFocusEffect } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 { getBootId } from '@/src/utils/bootSession';
import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme'; 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'); const { height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -97,6 +109,9 @@ export default function HomeScreen() {
const [likeFilled, setLikeFilled] = useState(false); const [likeFilled, setLikeFilled] = useState(false);
const [feedItems, setFeedItems] = useState<FeedItem[]>([]); const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
const [cardWidth, setCardWidth] = useState<number | null>(null);
const [wrappedText, setWrappedText] = useState<string>('');
const wrapLogRef = useRef<{ key: string } | null>(null);
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题: // 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行 // 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
@@ -176,6 +191,120 @@ export default function HomeScreen() {
}; };
}, [currentFeed, index]); }, [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 () => { const fetchNewFeed = useCallback(async () => {
if (isFetchingRef.current) return; if (isFetchingRef.current) return;
@@ -430,9 +559,15 @@ export default function HomeScreen() {
</CircleIconButton> </CircleIconButton>
</View> </View>
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}> <Animated.View
style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}
onLayout={(e) => {
const w = e.nativeEvent.layout.width;
if (Number.isFinite(w) && w > 0) setCardWidth(w);
}}
>
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}> <Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
{item.text} {wrappedText || item.text}
</Text> </Text>
</Animated.View> </Animated.View>

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

21
client/eas.json Normal file
View File

@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 16.32.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

View File

@@ -2240,304 +2240,304 @@ PODS:
- Yoga (0.0.0) - Yoga (0.0.0)
DEPENDENCIES: DEPENDENCIES:
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)" - EXApplication (from `../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`)" - EXConstants (from `../node_modules/expo-constants/ios`)
- "EXJSONUtils (from `../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios`)" - EXJSONUtils (from `../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`)" - EXManifests (from `../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`)" - EXNotifications (from `../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 (from `../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-client (from `../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-launcher (from `../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 (from `../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`)" - expo-dev-menu-interface (from `../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`)" - ExpoAsset (from `../node_modules/expo-asset/ios`)
- "ExpoCrypto (from `../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios`)" - ExpoCrypto (from `../node_modules/expo-crypto/ios`)
- "ExpoDevice (from `../node_modules/.pnpm/expo-device@8.0.10_expo@54.0.32/node_modules/expo-device/ios`)" - ExpoDevice (from `../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`)" - ExpoFileSystem (from `../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`)" - ExpoFont (from `../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`)" - ExpoHead (from `../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`)" - ExpoKeepAwake (from `../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`)" - ExpoLinearGradient (from `../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`)" - ExpoLinking (from `../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`)" - ExpoLocalization (from `../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`)" - ExpoModulesCore (from `../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`)" - ExpoSplashScreen (from `../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`)" - ExpoWebBrowser (from `../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`)" - EXUpdatesInterface (from `../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`)" - FBLazyVector (from `../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`)" - hermes-engine (from `../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`)" - RCTDeprecation (from `../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`)" - RCTRequired (from `../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`)" - RCTTypeSafety (from `../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 (from `../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-callinvoker (from `../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 (from `../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-prebuilt (from `../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-Core/RCTWebSocket (from `../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-CoreModules (from `../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-cxxreact (from `../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-debug (from `../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-defaultsnativemodule (from `../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-domnativemodule (from `../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-Fabric (from `../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-FabricComponents (from `../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-FabricImage (from `../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-featureflags (from `../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-featureflagsnativemodule (from `../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-graphics (from `../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-hermes (from `../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-idlecallbacksnativemodule (from `../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-ImageManager (from `../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-jserrorhandler (from `../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-jsi (from `../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-jsiexecutor (from `../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-jsinspector (from `../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-jsinspectorcdp (from `../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-jsinspectornetwork (from `../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-jsinspectortracing (from `../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-jsitooling (from `../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-jsitracing (from `../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-logger (from `../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-Mapbuffer (from `../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-microtasksnativemodule (from `../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-native-safe-area-context (from `../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-NativeModulesApple (from `../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-oscompat (from `../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-perflogger (from `../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-performancetimeline (from `../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-RCTActionSheet (from `../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-RCTAnimation (from `../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-RCTAppDelegate (from `../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-RCTBlob (from `../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-RCTFabric (from `../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-RCTFBReactNativeSpec (from `../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-RCTImage (from `../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-RCTLinking (from `../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-RCTNetwork (from `../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-RCTRuntime (from `../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-RCTSettings (from `../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-RCTText (from `../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-RCTVibration (from `../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-rendererconsistency (from `../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-renderercss (from `../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-rendererdebug (from `../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-RuntimeApple (from `../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-RuntimeCore (from `../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-runtimeexecutor (from `../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-RuntimeHermes (from `../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-runtimescheduler (from `../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-timing (from `../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`)" - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
- ReactAppDependencyProvider (from `build/generated/ios`) - ReactAppDependencyProvider (from `build/generated/ios`)
- ReactCodegen (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`)" - ReactCommon/turbomodule/core (from `../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`)" - ReactNativeDependencies (from `../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`)" - "RNCAsyncStorage (from `../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`)" - RNGestureHandler (from `../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`)" - RNReanimated (from `../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`)" - RNScreens (from `../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`)" - RNSVG (from `../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`)" - RNWorklets (from `../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`)" - Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
EXTERNAL SOURCES: EXTERNAL SOURCES:
EXApplication: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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 :tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782
RCTDeprecation: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: ReactAppDependencyProvider:
:path: build/generated/ios :path: build/generated/ios
ReactCodegen: ReactCodegen:
:path: build/generated/ios :path: build/generated/ios
ReactCommon: 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: 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: 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: 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: 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: 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: 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: 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: 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: SPEC CHECKSUMS:
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186 EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
@@ -2628,15 +2628,15 @@ SPEC CHECKSUMS:
React-timing: 03c7217455d2bff459b27a3811be25796b600f47 React-timing: 03c7217455d2bff459b27a3811be25796b600f47
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
ReactCodegen: 88a1f4643f15841573f833b895bfa2a0c6cb4e7f ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4 RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3 RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3
RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c RNReanimated: 43f611f1c85c90e0273df7399bf1536f8e2bd125
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51 RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528 RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad RNWorklets: 28ee7370ca8da356fcc914e3e68b97e9752196d2
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d

View File

@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 70; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -11,7 +11,8 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; }; 1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 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 */; }; A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; }; A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; }; A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
@@ -53,10 +54,11 @@
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; }; 3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; }; A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; }; A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; };
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; }; A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Screen_page.png; path = ../assets/images/Screen_page.png; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; }; C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
E3328F0E595C1F4A244DF238 /* libPods-client.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-client.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
EmotionWidget.swift, EmotionWidget.swift,
@@ -83,7 +85,18 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; }; EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "情绪小组件";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -120,6 +133,7 @@
13B07FB51A68108700A75B9A /* Images.xcassets */, 13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB61A68108700A75B9A /* Info.plist */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */,
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */, 75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */,
); );
name = client; name = client;
@@ -199,7 +213,7 @@
EB3DAFD42F2A5FC100450593 /* Recovered References */ = { EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */, A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
); );
name = "Recovered References"; name = "Recovered References";
sourceTree = "<group>"; sourceTree = "<group>";
@@ -307,6 +321,7 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
C0A1B2C3D4E5F60718293A4E /* Screen_page.png in Resources */,
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */, 0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -466,7 +481,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */, A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -621,7 +636,7 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = NO; 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; SDKROOT = iphoneos;
SKIP_INSTALL = NO; SKIP_INSTALL = NO;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -680,7 +695,7 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = 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; SDKROOT = iphoneos;
SKIP_INSTALL = NO; SKIP_INSTALL = NO;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;

View File

@@ -17,7 +17,7 @@
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/> <rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews> <subviews>
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLegacy" image="SplashScreenLegacy" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false"> <imageView id="EXPO-SplashScreen" userLabel="Screen_page" image="Screen_page" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
<rect key="frame" x="0" y="0" width="414" height="736"/> <rect key="frame" x="0" y="0" width="414" height="736"/>
</imageView> </imageView>
</subviews> </subviews>
@@ -37,7 +37,7 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="SplashScreenLegacy" width="414" height="736"/> <image name="Screen_page" width="414" height="736"/>
<systemColor name="systemBackgroundColor"> <systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor> </systemColor>

1540
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.10", "expo-web-browser": "~15.0.10",
"grapheme-splitter": "^1.0.4",
"i18next": "^25.8.0", "i18next": "^25.8.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
@@ -38,6 +39,7 @@
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1", "react-native-svg": "15.12.1",
"react-native-svg-transformer": "^1.5.3", "react-native-svg-transformer": "^1.5.3",
"react-native-text-size": "^4.0.0-rc.1",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1"
}, },

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { wrapText } from '../index';
describe('textWrap integration wrapText', () => {
it('SYSTEM_DEFAULTmeta 标记 SYSTEM_DEFAULT 且仍返回 lines/wrappedText', async () => {
const res = await wrapText({
text: 'I am so tired',
lang: 'EN',
context: 'APP',
availableWidth: 1,
maxLines: 1,
overflowMode: 'SYSTEM_DEFAULT',
// 不提供测量能力,逼迫走兜底
fontSpec: null,
measureWidthImpl: undefined,
});
expect(res.lines.length).toBeGreaterThan(0);
expect(res.wrappedText.length).toBeGreaterThan(0);
expect(res.meta?.fallback_type).toBe('SYSTEM_DEFAULT');
});
});

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { generateBreakpoints } from '../index';
function t(text: string, start: number): { text: string; start: number; end: number } {
return { text, start, end: start + text.length };
}
describe('textWrap breakpoint-candidates', () => {
it('EN: tokens=[I,am,so,tired] -> pos=[1,2,3](升序)', () => {
const tokens = [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)];
const { breakpoints, meta } = generateBreakpoints({
tokens: tokens as any,
lang: 'EN',
maxLines: 2,
config: { tcMaxCandidateBreaks: 80, tcPunctuations: ['', '。'], balanceRange: 3 },
});
expect(breakpoints.map((b) => b.pos)).toEqual([1, 2, 3]);
expect(meta.originalCount).toBe(3);
expect(meta.finalCount).toBe(3);
});
it('TC: 标点后断点(,)应生成 pos=i+1', () => {
const tokens = [t('我', 0), t('好', 1), t('累', 2), t('', 3), t('😮‍💨', 4)];
const { breakpoints } = generateBreakpoints({
tokens: tokens as any,
lang: 'TC',
maxLines: 2,
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [''], balanceRange: 0 },
});
expect(breakpoints.some((b) => b.kind === 'PUNCT' && b.pos === 4)).toBe(true);
});
it('TC: forbiddenBreakRanges闭区间命中必须剔除含 start/end', () => {
const tokens = [t('我', 0), t('好', 1), t('累', 2), t('', 3), t('😮‍💨', 4)];
const { breakpoints } = generateBreakpoints({
tokens: tokens as any,
lang: 'TC',
maxLines: 2,
constraints: { forbiddenBreakRanges: [{ start: 4, end: 4 }] },
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [''], balanceRange: 0 },
});
expect(breakpoints.some((b) => b.pos === 4)).toBe(false);
});
it('TC: 去重规则:同 pos 优先保留 priority 更高者;同 priority 按 kind 序', () => {
// 构造:同一个 pos=2 同时来自 SPACE(priority=20) 和 BALANCE(priority=5),必须保留 SPACE
const tokens = [t('我', 0), t(' ', 1), t('好', 2)];
const { breakpoints } = generateBreakpoints({
tokens: tokens as any,
lang: 'TC',
maxLines: 2,
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [], balanceRange: 2 },
});
const b2 = breakpoints.find((b) => b.pos === 2);
expect(b2?.kind).toBe('SPACE');
expect(b2?.priority).toBe(20);
});
it('TC: 裁剪上限生效且输出按 pos 升序(确定性)', () => {
const tokens = Array.from({ length: 60 }).map((_, i) => t('哈', i));
const res = generateBreakpoints({
tokens: tokens as any,
lang: 'TC',
maxLines: 3,
config: { tcMaxCandidateBreaks: 8, tcPunctuations: [], balanceRange: 6 },
});
expect(res.breakpoints.length).toBe(8);
// pos 升序
for (let i = 1; i < res.breakpoints.length; i++) {
expect(res.breakpoints[i]!.pos).toBeGreaterThanOrEqual(res.breakpoints[i - 1]!.pos);
}
// 确定性
const res2 = generateBreakpoints({
tokens: tokens as any,
lang: 'TC',
maxLines: 3,
config: { tcMaxCandidateBreaks: 8, tcPunctuations: [], balanceRange: 6 },
});
expect(res).toEqual(res2);
});
it('边界N<=1 时不输出任何候选断点', () => {
const { breakpoints } = generateBreakpoints({
tokens: [t('我', 0)] as any,
lang: 'TC',
maxLines: 2,
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [''], balanceRange: 3 },
});
expect(breakpoints).toEqual([]);
});
});

View File

@@ -0,0 +1,22 @@
import type { Breakpoint } from './types';
import type { Token } from '../core/types';
/**
* EN 候选断点生成(极简派)
*
* 口径:
* - tokens 仅为 WORD不包含 SPACE token
* - 候选断点只生成在 `pos ∈ [1, N-1]`
* - kind 固定为 SPACEpriority 固定为 10
*/
export function generateEnCandidates(tokens: Token[]): Breakpoint[] {
const n = tokens.length;
if (n <= 1) return [];
const out: Breakpoint[] = [];
for (let pos = 1; pos <= n - 1; pos++) {
out.push({ pos, kind: 'SPACE', priority: 10 });
}
return out;
}

View File

@@ -0,0 +1,89 @@
import type { Breakpoint, BreakpointKind, BreakpointMeta, BreakpointConstraints } from './types';
const KIND_ORDER: Record<BreakpointKind, number> = {
PUNCT: 0,
SPACE: 1,
BALANCE: 2,
OTHER: 3,
};
function betterBreakpoint(a: Breakpoint, b: Breakpoint): Breakpoint {
if (a.priority !== b.priority) return a.priority > b.priority ? a : b;
const ka = KIND_ORDER[a.kind] ?? 999;
const kb = KIND_ORDER[b.kind] ?? 999;
if (ka !== kb) return ka < kb ? a : b;
// 完全相同优先级时,稳定选择 pos 更小者(但同 pos 才会进入该比较)
return a;
}
/**
* forbiddenBreakRanges 过滤(闭区间口径)
* - start <= pos && pos <= end 命中则剔除
*/
export function filterForbiddenBreakRanges(
breakpoints: Breakpoint[],
constraints: BreakpointConstraints | undefined
): Breakpoint[] {
const ranges = constraints?.forbiddenBreakRanges;
if (!ranges || ranges.length === 0) return breakpoints;
return breakpoints.filter((bp) => {
for (const r of ranges) {
const s = r.start | 0;
const e = r.end | 0;
if (s <= bp.pos && bp.pos <= e) return false;
}
return true;
});
}
/**
* 去重:同 pos 只保留一个 breakpointpriority 更高者优先;同 priority 按 kind 固定序)
*/
export function dedupByPos(breakpoints: Breakpoint[]): Breakpoint[] {
const map = new Map<number, Breakpoint>();
for (const bp of breakpoints) {
const prev = map.get(bp.pos);
if (!prev) {
map.set(bp.pos, bp);
continue;
}
map.set(bp.pos, betterBreakpoint(prev, bp));
}
return Array.from(map.values());
}
export function distanceToNearestIdeal(pos: number, ideals: number[] | undefined): number {
if (!ideals || ideals.length === 0) return 1_000_000_000;
let best = 1_000_000_000;
for (const p of ideals) {
const d = Math.abs(pos - p);
if (d < best) best = d;
}
return best;
}
/**
* TC 裁剪(确定性排序后截断)
* - priority desc
* - distToIdeal asc
* - pos asc
*/
export function pruneTcCandidates(breakpoints: Breakpoint[], ideals: number[], tcMaxCandidateBreaks: number): Breakpoint[] {
if (breakpoints.length <= tcMaxCandidateBreaks) return breakpoints;
const sorted = [...breakpoints].sort((a, b) => {
if (a.priority !== b.priority) return b.priority - a.priority;
const da = distanceToNearestIdeal(a.pos, ideals);
const db = distanceToNearestIdeal(b.pos, ideals);
if (da !== db) return da - db;
return a.pos - b.pos;
});
return sorted.slice(0, tcMaxCandidateBreaks);
}
export function buildMeta(originalCount: number, finalCount: number): BreakpointMeta {
return { originalCount, finalCount, pruned: finalCount < originalCount };
}

View File

@@ -0,0 +1,59 @@
import type { Breakpoint, BreakpointMeta, GenerateBreakpointsInput } from './types';
import { generateEnCandidates } from './enCandidates';
import { generateTcCandidates } from './tcCandidates';
import { buildMeta, dedupByPos, filterForbiddenBreakRanges, pruneTcCandidates } from './filterAndDedup';
/**
* 生成候选断点(确定性)
*
* 统一口径:
* - 只输出 `pos ∈ [1, N-1]`
* - forbiddenBreakRanges 使用闭区间过滤start <= pos && pos <= end
* - 去重按 priority 优先priority 相同按 kind 固定序PUNCT > SPACE > BALANCE > OTHER
* - TC 超过上限时按固定排序裁剪,然后最终按 pos 升序输出
*/
export function generateBreakpoints(input: GenerateBreakpointsInput): { breakpoints: Breakpoint[]; meta: BreakpointMeta } {
const tokens = input.tokens ?? [];
const n = tokens.length;
const lang = input.lang;
if (n <= 1) {
return { breakpoints: [], meta: buildMeta(0, 0) };
}
let raw: Breakpoint[] = [];
let ideals: number[] = [];
if (lang === 'EN') {
raw = generateEnCandidates(tokens);
} else {
const out = generateTcCandidates(tokens, input.maxLines, {
tcPunctuations: input.config.tcPunctuations,
balanceRange: input.config.balanceRange,
});
raw = out.candidates;
ideals = out.idealPositions;
}
const originalCount = raw.length;
// 过滤 forbiddenBreakRanges闭区间
let filtered = filterForbiddenBreakRanges(raw, input.constraints);
// 去重(同 pos 保留最佳)
filtered = dedupByPos(filtered);
// TC 裁剪(规模上限)
if (lang === 'TC') {
const max = Math.max(0, input.config.tcMaxCandidateBreaks | 0);
if (max > 0) {
filtered = pruneTcCandidates(filtered, ideals, max);
}
}
// 最终按 pos 升序输出
filtered.sort((a, b) => a.pos - b.pos);
return { breakpoints: filtered, meta: buildMeta(originalCount, filtered.length) };
}

View File

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

View File

@@ -0,0 +1,78 @@
import type { Breakpoint } from './types';
import type { Token } from '../core/types';
type TcCandidateConfig = {
tcPunctuations: string[];
balanceRange: number;
};
/**
* 计算 BALANCE 的 idealPos 列表(确定性简化版)。
*
* - N=tokens.length
* - targetLines=min(maxLines,N)
* - lineIndex in 1..targetLines-1
* idealPos=round(N*lineIndex/targetLines)
*/
export function computeTcIdealPositions(tokens: Token[], maxLines: number): number[] {
const n = tokens.length;
const targetLines = Math.max(1, Math.min(maxLines | 0, n));
const ideals: number[] = [];
for (let lineIndex = 1; lineIndex <= targetLines - 1; lineIndex++) {
const idealPos = Math.round((n * lineIndex) / targetLines);
ideals.push(idealPos);
}
// 去重并排序(稳定)
ideals.sort((a, b) => a - b);
return ideals.filter((v, idx) => idx === 0 || v !== ideals[idx - 1]);
}
/**
* TC 候选断点生成:
* - 标点后kind=PUNCTpriority=30
* - 空格后kind=SPACEpriority=20
* - BALANCEkind=BALANCEpriority=5围绕 idealPos ± balanceRange
*
* 注意BALANCE 断点允许生成在短语 span 内,是否可用交给评分阶段强惩罚淘汰。
*/
export function generateTcCandidates(tokens: Token[], maxLines: number, config: TcCandidateConfig): { candidates: Breakpoint[]; idealPositions: number[] } {
const n = tokens.length;
if (n <= 1) return { candidates: [], idealPositions: [] };
const punctSet = new Set(config.tcPunctuations);
const candidates: Breakpoint[] = [];
// PUNCT / SPACE扫描 token
for (let i = 0; i < n; i++) {
const t = tokens[i]?.text ?? '';
const pos = i + 1;
// 只允许行内断点
if (pos < 1 || pos > n - 1) continue;
if (punctSet.has(t)) {
candidates.push({ pos, kind: 'PUNCT', priority: 30 });
}
if (t === ' ') {
candidates.push({ pos, kind: 'SPACE', priority: 20 });
}
}
// BALANCE
const idealPositions = computeTcIdealPositions(tokens, maxLines);
const range = Math.max(0, config.balanceRange | 0);
if (range > 0 && idealPositions.length > 0) {
for (const ideal of idealPositions) {
for (let pos = ideal - range; pos <= ideal + range; pos++) {
if (pos < 1 || pos > n - 1) continue;
candidates.push({ pos, kind: 'BALANCE', priority: 5 });
}
}
}
return { candidates, idealPositions };
}

View File

@@ -0,0 +1,37 @@
import type { Token } from '../core/types';
export type BreakpointKind = 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER';
export type Breakpoint = {
/** 断点位置token 边界):切分为 [0..pos) + [pos..N) */
pos: number;
kind: BreakpointKind;
/** 候选优先级(用于去重/裁剪阶段),越大越优先 */
priority: number;
};
export type BreakpointMeta = {
pruned: boolean;
originalCount: number;
finalCount: number;
};
export type BreakpointConstraints = {
protectedPhrases?: string[];
forbiddenBreakRanges?: Array<{ start: number; end: number }>;
};
export type BreakpointConfig = {
tcMaxCandidateBreaks: number;
tcPunctuations: string[];
balanceRange: number;
};
export type GenerateBreakpointsInput = {
tokens: Token[];
lang: 'TC' | 'EN';
maxLines: number;
constraints?: BreakpointConstraints;
config: BreakpointConfig;
};

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_PUNCTUATION_STRIP_SET_EN,
compareBreaksLexicographically,
joinTokens,
matchENKeyword,
normalizeENKeyword,
normalizeWhitespace,
tokenizeEN,
} from '../index';
describe('textWrap core-contract', () => {
it('normalizeWhitespace(NORMALIZE): 折叠空白 + 去首尾', () => {
const out = normalizeWhitespace(' a b \n c ', 'NORMALIZE');
expect(out.normalizedText).toBe('a b c');
expect(out.hadMultiWhitespace).toBe(true);
});
it('tokenizeEN: 只按空白分词(极简派,不拆标点)', () => {
const { normalizedText } = normalizeWhitespace('I am so tired.', 'NORMALIZE');
const tokens = tokenizeEN(normalizedText);
expect(tokens.map((t) => t.text)).toEqual(['I', 'am', 'so', 'tired.']);
});
it('joinTokens: 默认用单空格重组', () => {
const { normalizedText } = normalizeWhitespace('I am so tired', 'NORMALIZE');
const tokens = tokenizeEN(normalizedText);
expect(joinTokens(tokens, 0, 2)).toBe('I am');
expect(joinTokens(tokens, 2, 4)).toBe('so tired');
});
it('normalizeENKeyword/matchENKeyword: 全词等值匹配,不做 substring', () => {
const config = { punctuationStripSetEN: DEFAULT_PUNCTUATION_STRIP_SET_EN };
expect(normalizeENKeyword('BUT,', config)).toBe('but');
expect(matchENKeyword('but,', 'but', config)).toBe(true);
expect(matchENKeyword('rebuttal', 'but', config)).toBe(false);
});
it('compareBreaksLexicographically: 字典序 + 短数组优先', () => {
expect(compareBreaksLexicographically([2], [3])).toBeLessThan(0);
expect(compareBreaksLexicographically([2], [2, 5])).toBeLessThan(0);
expect(compareBreaksLexicographically([2, 3], [2])).toBeGreaterThan(0);
expect(compareBreaksLexicographically([2, 3], [2, 3])).toBe(0);
});
});

View File

@@ -0,0 +1,25 @@
/**
* breaks[] 字典序比较(确定性口径)
*
* 规则(必须):
* - 从 index=0 起逐项比较:
* - 首个不同元素更小者视为更小
* - 若公共前缀完全相同:
* - 更短的数组视为更小
*/
export function compareBreaksLexicographically(a: number[], b: number[]): number {
const aa = Array.isArray(a) ? a : [];
const bb = Array.isArray(b) ? b : [];
const n = Math.min(aa.length, bb.length);
for (let i = 0; i < n; i++) {
const x = aa[i]!;
const y = bb[i]!;
if (x === y) continue;
return x < y ? -1 : 1;
}
if (aa.length === bb.length) return 0;
return aa.length < bb.length ? -1 : 1;
}

View File

@@ -0,0 +1,68 @@
import type { CoreConfig } from './types';
/**
* EN 常见标点(默认集合)
*
* 说明:此集合只用于“关键词命中判定”的两端 strip不影响 tokenize仍为极简派
* 必须全端一致;后续若扩展,也必须通过 configVersion 管理。
*/
export const DEFAULT_PUNCTUATION_STRIP_SET_EN: string[] = [
',',
'.',
'!',
'?',
':',
';',
'"',
"'",
'…',
'—',
'',
'(',
')',
'[',
']',
'{',
'}',
];
function buildStripSet(config?: Pick<CoreConfig, 'punctuationStripSetEN'>): Set<string> {
const list = config?.punctuationStripSetEN?.length ? config.punctuationStripSetEN : DEFAULT_PUNCTUATION_STRIP_SET_EN;
return new Set(list);
}
/**
* 归一化一个 token用于 EN 关键词命中(全词等值匹配)。
*
* 口径(必须):
* - lowercase
* - strip 两端常见标点(可重复剥离,例如 `"\"but,\""`
* - 不允许 substring/contains
*/
export function normalizeENKeyword(tokenText: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): string {
const stripSet = buildStripSet(config);
let s = String(tokenText ?? '').toLowerCase();
if (!s) return '';
// 仅剥离两端的“常见标点”,内部字符不处理
let start = 0;
let end = s.length;
while (start < end && stripSet.has(s[start]!)) start++;
while (end > start && stripSet.has(s[end - 1]!)) end--;
// 如果剥离后还剩空白,再做一次 trim避免 `"but "` 这种输入)
return s.slice(start, end).trim();
}
/**
* EN 关键词命中:全词等值匹配(禁止 substring
*/
export function matchENKeyword(tokenText: string, keyword: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): boolean {
const a = normalizeENKeyword(tokenText, config);
const b = normalizeENKeyword(keyword, config);
if (!a || !b) return false;
return a === b;
}

View File

@@ -0,0 +1,8 @@
export type { CoreConfig, Lang, NormalizeWhitespaceResult, Token, WhitespacePolicy } from './types';
export { normalizeWhitespace } from './normalizeWhitespace';
export { tokenizeEN } from './tokenizeEN';
export { joinTokens } from './joinTokens';
export { DEFAULT_PUNCTUATION_STRIP_SET_EN, matchENKeyword, normalizeENKeyword } from './enKeyword';
export { compareBreaksLexicographically } from './compare';

View File

@@ -0,0 +1,37 @@
import type { Token } from './types';
/**
* 从 token 区间 `[start..end)` 重组回文本(跨端口径)。
*
* - 默认:用单空格 join适用于 whitespacePolicy=NORMALIZE
* - 若传入 rawSeparators按原始分隔符拼接适用于 whitespacePolicy=PRESERVE
*
* 说明:
* - `start/end` 为半开区间
* - 空区间返回空字符串;上层需要用硬约束避免空行
*/
export function joinTokens(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
const s = Math.max(0, start | 0);
const e = Math.max(0, end | 0);
if (!Array.isArray(tokens) || tokens.length === 0) return '';
if (s >= e) return '';
const slice = tokens.slice(s, e).map((t) => t.text);
if (!rawSeparators) {
return slice.join(' ');
}
// rawSeparators[i] 表示 tokens[i] 与 tokens[i+1] 之间的分隔符
// 这里只实现最小可用:若 separators 缺失则回退到单空格。
let out = '';
for (let idx = s; idx < e; idx++) {
const t = tokens[idx];
if (!t) continue;
out += t.text;
if (idx < e - 1) {
out += rawSeparators[idx] ?? ' ';
}
}
return out;
}

View File

@@ -0,0 +1,28 @@
import type { NormalizeWhitespaceResult, WhitespacePolicy } from './types';
/**
* 空白归一化(跨端口径)
*
* - NORMALIZE折叠连续空白为单空格并去首尾空白
* - PRESERVE仅去首尾空白内部空白保持原样
*
* 注意:该函数会改变输入文本(至少会 trim必须作为“算法契约”的一部分固定下来。
*/
export function normalizeWhitespace(text: string, policy: WhitespacePolicy = 'NORMALIZE'): NormalizeWhitespaceResult {
const input = String(text ?? '');
if (policy === 'PRESERVE') {
const trimmed = input.trim();
// 只要发生 trim或内部存在非单空格的分隔如 \n/\t/多个空格),都算 hadMultiWhitespace
const hadMultiWhitespace = trimmed !== input || /[\t\r\n]/.test(trimmed) || / +/.test(trimmed);
return { normalizedText: trimmed, hadMultiWhitespace };
}
// NORMALIZE把任意连续空白折叠为 1 个空格,并去首尾空白
// 说明:这里使用 \s+ 覆盖空格/换行/制表等;跨端需要保证采用同等语义的实现。
const trimmed = input.trim();
const collapsed = trimmed.replace(/\s+/g, ' ');
const hadMultiWhitespace = collapsed !== input;
return { normalizedText: collapsed, hadMultiWhitespace };
}

View File

@@ -0,0 +1,38 @@
import type { Token } from './types';
/**
* EN tokenize极简派
*
* 口径:
* - tokens 只包含 WORD不生成 SPACE token
* - 标点视为“词内字符”,不额外拆分(例如 "tired."、"Wait..."、"hello—world" 都是一个 token
* - 断点只允许发生在“词与词之间”(由上层生成 breakpoint 时遵守)
*
* start/end 索引以 normalizedText 为基准(建议先调用 normalizeWhitespace
*/
export function tokenizeEN(normalizedText: string): Token[] {
const s = String(normalizedText ?? '');
if (!s) return [];
const tokens: Token[] = [];
// 由于 NORMALIZE 模式下空白都折叠为单空格,这里按空格扫描即可。
// 为了稳健性,也允许出现意外多空格(会跳过空段)。
let i = 0;
const n = s.length;
while (i < n) {
// 跳过空格(或其他空白字符)
while (i < n && /\s/.test(s[i]!)) i++;
if (i >= n) break;
const start = i;
while (i < n && !/\s/.test(s[i]!)) i++;
const end = i;
tokens.push({ text: s.slice(start, end), start, end });
}
return tokens;
}

View File

@@ -0,0 +1,40 @@
/**
* Text Wrap - core-contract
*
* 本文件定义“跨端一致的基础口径”所需的最小类型集合。
* 注意这里的索引start/end默认以 normalizedText归一化后的文本为基准。
*/
export type Lang = 'TC' | 'EN';
export type WhitespacePolicy = 'NORMALIZE' | 'PRESERVE';
export type Token = {
/** token 的原始文本ENTC字符簇 */
text: string;
/** token 在 normalizedText 中的起始索引(包含) */
start: number;
/** token 在 normalizedText 中的结束索引(不包含) */
end: number;
};
export type NormalizeWhitespaceResult = {
/**
* 归一化后的文本。
* - NORMALIZE折叠连续空白为单空格并去首尾空白
* - PRESERVE仅去首尾空白内部空白保持原样
*/
normalizedText: string;
/** 是否发生过“空白折叠/trim/非单空格分隔”等(用于后续 meta 打点) */
hadMultiWhitespace: boolean;
};
export type CoreConfig = {
whitespacePolicy: WhitespacePolicy;
/**
* EN 关键词命中:两端可剥离的常见标点集合(必须全端一致)。
* 来源建议与默认值参考:`设计说明文档/文档换行算法.md`v1.2.12.3.1A-1
*/
punctuationStripSetEN: string[];
};

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import type { Token } from '../../core/types';
import { normalizeWhitespace, tokenizeEN } from '../../core/index';
import { segmentGraphemes } from '../../grapheme/index';
import { wrapText } from '../../index';
import { GOLDEN_CASES } from '../fixtures';
function tokenizeTC(normalizedText: string): Token[] {
const { clusters } = segmentGraphemes(normalizedText, 'PREFERRED');
const tokens: Token[] = [];
let idx = 0;
for (const c of clusters) {
const start = idx;
idx += c.length;
tokens.push({ text: c, start, end: idx });
}
return tokens;
}
async function wrapTextHarness(args: {
text: string;
lang: 'TC' | 'EN';
context: 'APP' | 'WIDGET';
availableWidth: number;
maxLines: number;
}): Promise<{ ok: true; lines: string[]; wrappedText: string } | { ok: false; detail: any }> {
const out = await wrapText({
text: args.text,
lang: args.lang,
context: args.context,
availableWidth: args.availableWidth,
maxLines: args.maxLines,
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: async ({ text }) => text.length,
contextProfile: `${args.context}|golden`,
lineMode: 'AUTO',
debug: true,
});
// wrapText 统一返回成功形态;若进入 SYSTEM_DEFAULT 等兜底meta 会标记
return { ok: true, lines: out.lines, wrappedText: out.wrappedText };
}
describe('textWrap golden-tests', () => {
for (const c of GOLDEN_CASES) {
it(`golden:${c.id}`, async () => {
const res = await wrapTextHarness({
text: c.text,
lang: c.lang,
context: c.context,
availableWidth: c.availableWidth,
maxLines: c.maxLines,
});
if (!res.ok) {
throw new Error(`golden case failed: ${c.id}\n${JSON.stringify(res.detail, null, 2)}`);
}
if (res.ok) {
expect(res.lines).toEqual(c.expected.lines);
expect(res.wrappedText).toBe(c.expected.wrappedText);
}
});
}
it('property: 确定性(同输入多次调用一致)', async () => {
const args = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, availableWidth: 6, maxLines: 2 };
const a = await wrapTextHarness(args);
const b = await wrapTextHarness(args);
const c = await wrapTextHarness(args);
expect(a).toEqual(b);
expect(b).toEqual(c);
});
it('property: 近似单调性availableWidth 变小不会让最大行宽变大)', async () => {
const base = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, maxLines: 2 };
const wide = await wrapTextHarness({ ...base, availableWidth: 20 });
const narrow = await wrapTextHarness({ ...base, availableWidth: 7 });
if (!wide.ok || !narrow.ok) {
throw new Error(`property failed: expected both ok\nwide=${JSON.stringify(wide)}\nnarrow=${JSON.stringify(narrow)}`);
}
const maxW = (lines: string[]) => Math.max(...lines.map((s) => s.length));
expect(maxW(narrow.lines)).toBeLessThanOrEqual(maxW(wide.lines));
});
it('property: maxLines 不变差maxLines 增加不应从可解变无解)', async () => {
const base = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, availableWidth: 7 };
const a = await wrapTextHarness({ ...base, maxLines: 2 });
const b = await wrapTextHarness({ ...base, maxLines: 3 });
expect(a.ok).toBe(true);
expect(b.ok).toBe(true);
});
});

View File

@@ -0,0 +1,56 @@
export type GoldenCase = {
id: string;
text: string;
lang: 'TC' | 'EN';
context: 'APP' | 'WIDGET';
availableWidth: number;
maxLines: number;
expected: { lines: string[]; wrappedText: string };
};
/**
* Golden fixtures首版最小可运行集
*
* 说明:
* - APP测量 mock=string.length因此 availableWidth 也是“字符数单位”
* - WIDGETwidthMode=APPROX因此 availableWidth 是“token 数单位”
*/
export const GOLDEN_CASES: GoldenCase[] = [
{
id: 'en_app_simple_2lines',
text: 'I am so tired',
lang: 'EN',
context: 'APP',
availableWidth: 7,
maxLines: 2,
expected: { lines: ['I am so', 'tired'], wrappedText: 'I am so\ntired' },
},
{
id: 'en_app_punct_keyword',
text: 'but, still ok',
lang: 'EN',
context: 'APP',
availableWidth: 9,
maxLines: 2,
expected: { lines: ['but,', 'still ok'], wrappedText: 'but,\nstill ok' },
},
{
id: 'tc_app_punct_2lines',
text: '我好累,😮‍💨',
lang: 'TC',
context: 'APP',
availableWidth: 6,
maxLines: 2,
expected: { lines: ['我好累,', '😮‍💨'], wrappedText: '我好累,\n😮💨' },
},
{
id: 'tc_widget_approx_2lines',
text: '我好累',
lang: 'TC',
context: 'WIDGET',
availableWidth: 2,
maxLines: 2,
expected: { lines: ['我', '好累'], wrappedText: '我\n好累' },
},
];

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { segmentGraphemes } from '../index';
const REQUIRED_SINGLE_CLUSTER_CASES: Array<{ name: string; input: string }> = [
{ name: 'ZWJ family', input: '👨‍👩‍👧‍👦' },
{ name: 'flag', input: '🇸🇬' },
{ name: 'skin tone', input: '👍🏽' },
{ name: 'ZWJ + variation', input: '😮‍💨' },
{ name: 'combining mark', input: 'e\u0301' }, // é组合形式
];
describe('textWrap grapheme-segmentation', () => {
it('基础性质:空字符串 -> []', () => {
const out = segmentGraphemes('', 'PREFERRED');
expect(out.clusters).toEqual([]);
expect(out.clusters.join('')).toBe('');
});
it('基础性质可逆性clusters.join("") === input', () => {
const input = '我好累😮‍💨';
const out = segmentGraphemes(input, 'PREFERRED');
expect(out.clusters.join('')).toBe(input);
});
it('基础性质:确定性(同输入同输出)', () => {
const input = '👨👩👧👦🇸🇬👍🏽😮💨e\u0301';
const a = segmentGraphemes(input, 'PREFERRED');
const b = segmentGraphemes(input, 'PREFERRED');
expect(a).toEqual(b);
});
it('必测样例:在 PREFERRED 下必须“不拆”为单个 cluster', () => {
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
const out = segmentGraphemes(c.input, 'PREFERRED');
expect(out.clusters.join('')).toBe(c.input);
expect(out.clusters.length).toBe(1);
expect(out.clusters[0]).toBe(c.input);
}
});
it('必测样例:在强制 FALLBACK 下必须“不拆”为单个 cluster', () => {
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
const out = segmentGraphemes(c.input, 'FALLBACK');
expect(out.meta.strategy).toBe('FALLBACK');
expect(out.meta.hadFallback).toBe(true);
expect(out.clusters.join('')).toBe(c.input);
expect(out.clusters.length).toBe(1);
expect(out.clusters[0]).toBe(c.input);
}
});
it('跨策略一致性:若 Intl.Segmenter 可用,则 PREFERRED 与 FALLBACK 输出应一致', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasIntl = typeof (globalThis as any)?.Intl?.Segmenter === 'function';
if (!hasIntl) return;
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
const preferred = segmentGraphemes(c.input, 'PREFERRED');
const fallback = segmentGraphemes(c.input, 'FALLBACK');
expect(preferred.clusters).toEqual(fallback.clusters);
}
});
});

View File

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

View File

@@ -0,0 +1,42 @@
import type { GraphemeSegmentationMode, GraphemeSegmentationResult } from './types';
import { canUseIntlSegmenter, segmentWithIntlSegmenter } from './strategies/intlSegmenter';
import { segmentWithFallback } from './strategies/fallback';
/**
* 把字符串切分为 grapheme clusters字符簇
*
* 契约(必须):
* - clusters.join('') === text不允许丢字符/改顺序)
* - text=='' 时 clusters==[]
* - 任何异常都必须降级到 fallback保证可用性
*/
export function segmentGraphemes(text: string, mode: GraphemeSegmentationMode = 'PREFERRED'): GraphemeSegmentationResult {
const input = String(text ?? '');
if (input === '') {
return { clusters: [], meta: { strategy: 'FALLBACK', hadFallback: mode === 'FALLBACK' } };
}
// 强制 fallback
if (mode === 'FALLBACK') {
const clusters = segmentWithFallback(input);
return { clusters, meta: { strategy: 'FALLBACK', hadFallback: true } };
}
// 优先 Intl.Segmenter
if (canUseIntlSegmenter()) {
try {
const clusters = segmentWithIntlSegmenter(input);
// 防御:必须可逆
if (clusters.length > 0 && clusters.join('') === input) {
return { clusters, meta: { strategy: 'INTL_SEGMENTER', hadFallback: false } };
}
// 若结果异常(空/丢字符),走 fallback
} catch {
// ignore -> fallback
}
}
const clusters = segmentWithFallback(input);
return { clusters, meta: { strategy: 'FALLBACK', hadFallback: true } };
}

View File

@@ -0,0 +1,81 @@
import GraphemeSplitter from 'grapheme-splitter';
/**
* fallback 策略:使用 grapheme-splitter 分割 grapheme clusters。
*
* 选择原因:
* - 避免手写不完整的 Unicode 规则导致“漏拆/误拆”
* - 作为 Intl.Segmenter 不可用时的稳定兜底
*
* 注意:
* - grapheme-splitter 对少数较新的 emoji ZWJ 序列支持可能不完整(例如 `😮‍💨`)。
* - 为满足本项目“至少不拆 ZWJ sequence/VS16/肤色修饰符/组合字符”的最低要求,这里做一层确定性的后处理合并。
*/
export function segmentWithFallback(text: string): string[] {
const splitter = new GraphemeSplitter();
const raw = splitter.splitGraphemes(text);
return mergeFallbackClusters(raw);
}
const ZWJ = '\u200D';
function firstCodePoint(s: string): number | null {
if (!s) return null;
const cp = s.codePointAt(0);
return typeof cp === 'number' ? cp : null;
}
function isVariationSelector(cp: number): boolean {
// VS15/VS16
return cp === 0xfe0e || cp === 0xfe0f;
}
function isSkinToneModifier(cp: number): boolean {
return cp >= 0x1f3fb && cp <= 0x1f3ff;
}
function isCombiningMark(cp: number): boolean {
// 常见 combining marks 范围(覆盖 `e\u0301` 等)
return (
(cp >= 0x0300 && cp <= 0x036f) ||
(cp >= 0x1ab0 && cp <= 0x1aff) ||
(cp >= 0x1dc0 && cp <= 0x1dff) ||
(cp >= 0x20d0 && cp <= 0x20ff) ||
(cp >= 0xfe20 && cp <= 0xfe2f)
);
}
/**
* 对 fallback 输出做“最低可用”合并:
* - 若上一个 cluster 以 ZWJ 结尾则必须与下一个合并ZWJ sequence
* - 若下一个 cluster 以 VS/肤色修饰符/combining mark 开头,也与上一个合并
*
* 目标:满足文档列出的“不拆”组合要求,且行为完全确定性。
*/
function mergeFallbackClusters(raw: string[]): string[] {
if (!raw.length) return raw;
const out: string[] = [];
let buf = raw[0] ?? '';
for (let i = 1; i < raw.length; i++) {
const next = raw[i] ?? '';
const nextCp = firstCodePoint(next);
const shouldMerge =
buf.endsWith(ZWJ) ||
(nextCp !== null && (isVariationSelector(nextCp) || isSkinToneModifier(nextCp) || isCombiningMark(nextCp)));
if (shouldMerge) {
buf += next;
continue;
}
out.push(buf);
buf = next;
}
out.push(buf);
return out;
}

View File

@@ -0,0 +1,28 @@
/**
* Intl.Segmenter 策略(优先)
*
* 注意:不同 JS 引擎/版本对 Intl.Segmenter 的支持可能不同,调用方必须捕获异常并降级。
*/
export function canUseIntlSegmenter(): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Seg = (globalThis as any)?.Intl?.Segmenter;
return typeof Seg === 'function';
}
export function segmentWithIntlSegmenter(text: string): string[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Segmenter = (globalThis as any).Intl.Segmenter as new (locales?: string | string[], options?: any) => any;
// 用 zh-Hant 仅用于选择合适的 localegrapheme 分割应与语言本身关系不大,但保持固定输入更易对齐跨端。
const seg = new Segmenter('zh-Hant', { granularity: 'grapheme' });
const it = seg.segment(text);
const clusters: string[] = [];
for (const part of it) {
// part: { segment: string, index: number, input: string, isWordLike?: boolean }
clusters.push(part.segment);
}
return clusters;
}

View File

@@ -0,0 +1,21 @@
/**
* Text Wrap - grapheme-segmentation
*
* 本模块只负责把字符串切成“字符簇grapheme clusters”数组。
* 后续 TC 换行断点只能发生在 clusters 边界。
*/
export type GraphemeSegmentationMode = 'PREFERRED' | 'FALLBACK';
export type GraphemeSegmentationStrategy = 'INTL_SEGMENTER' | 'FALLBACK';
export type GraphemeSegmentationMeta = {
strategy: GraphemeSegmentationStrategy;
hadFallback: boolean;
};
export type GraphemeSegmentationResult = {
clusters: string[];
meta: GraphemeSegmentationMeta;
};

View File

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

View File

@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';
import type { MeasureWidthImpl } from '../types';
import { MissingFontSpecError } from '../errors';
import { buildFontSpecKey } from '../fontSpecKey';
import { measureSliceWidthCached } from '../measureSliceWidthCached';
import { measureWidthCached } from '../measureWidthCached';
describe('textWrap width-measurement', () => {
it('buildFontSpecKey: 缺字段必须报错(简体中文)', () => {
expect(() => buildFontSpecKey({ fontFamily: 'PingFangSC-Regular', fontWeight: '400' } as any)).toThrow(
MissingFontSpecError
);
});
it('measureWidthCached: 未提供测量能力 -> approx + WIDTH_UNKNOWN', async () => {
const res = await measureWidthCached({
text: 'hello',
context: 'WIDGET',
contextProfile: 'WIDGET',
fontSpec: null,
measureWidthImpl: undefined,
});
expect(res.width).toBeNull();
expect(res.meta).toEqual({ isApprox: true, reason: 'WIDTH_UNKNOWN' });
});
it('measureWidthCached: 同 key 命中缓存(不会重复调用底层测量)', async () => {
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
const a = await measureWidthCached({
text: 'abc',
context: 'APP',
contextProfile: 'APP|ios',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
});
const b = await measureWidthCached({
text: 'abc',
context: 'APP',
contextProfile: 'APP|ios',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
});
expect(a.width).toBe(3);
expect(b.width).toBe(3);
expect(impl).toHaveBeenCalledTimes(1);
});
it('measureWidthCached: 测量抛错 -> approx + MEASURE_FAILED', async () => {
const impl: MeasureWidthImpl = vi.fn(async () => {
throw new Error('boom');
});
const res = await measureWidthCached({
text: 'abc',
context: 'APP',
contextProfile: 'APP|ios|throw',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
});
expect(res.width).toBeNull();
expect(res.meta).toEqual({ isApprox: true, reason: 'MEASURE_FAILED' });
});
it('measureSliceWidthCached: 同 sliceKey 命中缓存', async () => {
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
const tokens = [
{ text: 'I', start: 0, end: 1 },
{ text: 'am', start: 2, end: 4 },
{ text: 'tired', start: 5, end: 10 },
];
const a = await measureSliceWidthCached({
tokens: tokens as any,
start: 0,
end: 2,
context: 'APP',
contextProfile: 'APP|ios',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
});
const b = await measureSliceWidthCached({
tokens: tokens as any,
start: 0,
end: 2,
context: 'APP',
contextProfile: 'APP|ios',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
});
expect(a.width).toBe('I am'.length);
expect(b.width).toBe('I am'.length);
expect(impl).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,62 @@
/**
* Text Wrap - width-measurement 缓存
*
* 要求:
* - 模块级常驻缓存(跨调用复用)
* - 有容量上限(避免内存无限增长)
* - Key 必须确定性(由上层拼接传入)
*
* 说明:
* - 这里实现一个最小 LRUMap 维护插入顺序get 时“刷新”为最新。
* - 缓存 value 允许是 Promise以便并发请求去重同 key 只测一次)。
*/
export class LruCache<V> {
private readonly maxSize: number;
private readonly map: Map<string, V>;
constructor(maxSize: number) {
if (!Number.isFinite(maxSize) || maxSize <= 0) {
throw new Error('LRU 缓存 maxSize 必须为正数。');
}
this.maxSize = Math.floor(maxSize);
this.map = new Map();
}
get(key: string): V | undefined {
const v = this.map.get(key);
if (v === undefined) return undefined;
// 刷新为最新
this.map.delete(key);
this.map.set(key, v);
return v;
}
set(key: string, value: V): void {
if (this.map.has(key)) {
this.map.delete(key);
}
this.map.set(key, value);
if (this.map.size > this.maxSize) {
// 淘汰最旧的
const oldestKey = this.map.keys().next().value as string | undefined;
if (oldestKey !== undefined) {
this.map.delete(oldestKey);
}
}
}
delete(key: string): void {
this.map.delete(key);
}
clear(): void {
this.map.clear();
}
size(): number {
return this.map.size;
}
}

View File

@@ -0,0 +1,19 @@
/**
* Text Wrap - width-measurement 错误定义
*
* 约束fontSpec 缺字段必须直接报错(简体中文),禁止隐式默认值。
*/
export class MissingFontSpecError extends Error {
readonly name = 'MissingFontSpecError';
constructor(message: string) {
super(message);
}
}
export function buildMissingFontSpecMessage(missingFields: string[]): string {
const fields = missingFields.join(', ');
return `fontSpec 缺少必填字段:${fields}。请在调用 wrapText() 时补齐 fontSpecfontFamily/fontWeight/fontSize禁止在测量层使用隐式默认值。`;
}

View File

@@ -0,0 +1,32 @@
import type { FontSpec } from './types';
import { MissingFontSpecError, buildMissingFontSpecMessage } from './errors';
/**
* 校验 fontSpec 必填字段。
* 约束:缺失字段必须报错(简体中文),避免跨端漂移。
*/
export function assertFontSpecComplete(fontSpec: Partial<FontSpec> | undefined | null): asserts fontSpec is FontSpec {
if (!fontSpec) {
throw new MissingFontSpecError(buildMissingFontSpecMessage(['fontFamily', 'fontWeight', 'fontSize']));
}
const missing: string[] = [];
if (!fontSpec.fontFamily) missing.push('fontFamily');
if (!fontSpec.fontWeight) missing.push('fontWeight');
if (fontSpec.fontSize === undefined || fontSpec.fontSize === null) missing.push('fontSize');
if (missing.length) throw new MissingFontSpecError(buildMissingFontSpecMessage(missing));
if (!Number.isFinite(fontSpec.fontSize)) {
throw new MissingFontSpecError('fontSpec.fontSize 必须是有限数值number。请检查调用 wrapText() 时传入的 fontSize。');
}
}
/**
* 生成 fontSpecKey确定性
* 规则:`fontFamily|fontWeight|fontSize`(顺序固定)
*/
export function buildFontSpecKey(fontSpec: Partial<FontSpec> | undefined | null): string {
assertFontSpecComplete(fontSpec);
return `${fontSpec.fontFamily}|${fontSpec.fontWeight}|${String(fontSpec.fontSize)}`;
}

View File

@@ -0,0 +1,16 @@
export type {
ContextProfile,
FontSpec,
MeasureMeta,
MeasureReason,
MeasureResult,
MeasureWidthImpl,
TextWrapContext,
} from './types';
export { MissingFontSpecError } from './errors';
export { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
export { defaultMeasureWidthImpl } from './measureWidthImpl';
export { measureWidthCached } from './measureWidthCached';
export { measureSliceWidthCached } from './measureSliceWidthCached';

View File

@@ -0,0 +1,105 @@
import type { ContextProfile, FontSpec, MeasureResult, MeasureWidthImpl, TextWrapContext } from './types';
import type { Token } from '../core/types';
import { LruCache } from './cache';
import { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
import { joinTokens } from '../core/joinTokens';
import { measureWidthCached } from './measureWidthCached';
// 切片缓存:同一 (start,end,fontSpecKey,contextProfile) 的宽度只测一次
const sliceWidthCache = new LruCache<Promise<number>>(4000);
function fnv1a32(input: string): string {
// 32-bit FNV-1a确定性、实现简单用于缓存 key 避免过长)
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
// hash *= 16777619用位运算模拟 32-bit 溢出)
hash = (hash + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
function buildSliceKey(args: {
contextProfile: ContextProfile;
fontSpecKey: string;
start: number;
end: number;
sliceTextHash: string;
}): string {
// 注意:必须包含 sliceText 的信息,否则不同文本的相同 (start,end) 会发生跨文本串缓存
return `${args.contextProfile}|${args.fontSpecKey}|${args.start}|${args.end}|${args.sliceTextHash}`;
}
/**
* 测量 token 切片 `[start..end)` 的宽度(带缓存)。
*
* - 切片文本通过 `joinTokens()` 生成(保证与 core-contract 的重组口径一致)
* - 内部复用 `measureWidthCached` 的降级语义
*/
export async function measureSliceWidthCached(args: {
tokens: Token[];
start: number;
end: number;
context: TextWrapContext;
contextProfile: ContextProfile;
fontSpec?: Partial<FontSpec> | null;
measureWidthImpl?: MeasureWidthImpl;
widgetEnableMeasure?: boolean;
rawSeparators?: string[];
}): Promise<MeasureResult> {
const enabled =
typeof args.measureWidthImpl === 'function' && (args.context === 'APP' || args.widgetEnableMeasure === true);
if (!enabled) {
// 不启用测量时直接走 width=null保持与 measureWidthCached 一致)
return { width: null, meta: { isApprox: true, reason: 'WIDTH_UNKNOWN' } };
}
assertFontSpecComplete(args.fontSpec);
const fontSpecKey = buildFontSpecKey(args.fontSpec);
const sliceText = joinTokens(args.tokens, args.start, args.end, args.rawSeparators);
const key = buildSliceKey({
contextProfile: args.contextProfile,
fontSpecKey,
start: args.start,
end: args.end,
sliceTextHash: fnv1a32(sliceText),
});
const cached = sliceWidthCache.get(key);
if (cached) {
try {
const w = await cached;
return { width: w, meta: { isApprox: false } };
} catch {
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
}
}
const promise = (async () => {
const res = await measureWidthCached({
text: sliceText,
context: args.context,
contextProfile: args.contextProfile,
fontSpec: args.fontSpec,
measureWidthImpl: args.measureWidthImpl,
widgetEnableMeasure: args.widgetEnableMeasure,
});
if (res.width === null) {
throw new Error('切片测量失败或不可用。');
}
return res.width;
})();
sliceWidthCache.set(key, promise);
try {
const w = await promise;
return { width: w, meta: { isApprox: false } };
} catch {
sliceWidthCache.delete(key);
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
}
}

View File

@@ -0,0 +1,110 @@
import type { ContextProfile, FontSpec, MeasureResult, MeasureWidthImpl, TextWrapContext } from './types';
import { LruCache } from './cache';
import { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
// 模块级常驻缓存(两级)
const textWidthCache = new LruCache<Promise<number>>(2000);
const loggedMeasureFailures = new Set<string>();
function buildTextKey(args: { contextProfile: ContextProfile; fontSpecKey: string; text: string }): string {
// key 拼接规则必须确定性:<contextProfile>|<fontSpecKey>|<text>
return `${args.contextProfile}|${args.fontSpecKey}|${args.text}`;
}
function isValidWidth(v: unknown): v is number {
return typeof v === 'number' && Number.isFinite(v) && v >= 0;
}
/**
* 测量文本宽度(带缓存)。
*
* 约束:
* - 若启用测量(提供 measureWidthImplfontSpec 必须完整;缺字段直接报错(简体中文)
* - 若不启用测量measureWidthImpl 缺失或 context=WIDGET 且明确不启用),进入 approx modewidth=null
*/
export async function measureWidthCached(args: {
text: string;
context: TextWrapContext;
contextProfile: ContextProfile;
fontSpec?: Partial<FontSpec> | null;
measureWidthImpl?: MeasureWidthImpl;
/** WIDGET 场景是否启用测量;默认 false未启用即 WIDTH_UNKNOWN */
widgetEnableMeasure?: boolean;
}): Promise<MeasureResult> {
const text = String(args.text ?? '');
const enabled =
typeof args.measureWidthImpl === 'function' && (args.context === 'APP' || args.widgetEnableMeasure === true);
if (!enabled) {
return { width: null, meta: { isApprox: true, reason: 'WIDTH_UNKNOWN' } };
}
// 启用测量时fontSpec 缺字段必须报错
const fontSpec = args.fontSpec;
assertFontSpecComplete(fontSpec);
const fontSpecKey = buildFontSpecKey(fontSpec);
const key = buildTextKey({ contextProfile: args.contextProfile, fontSpecKey, text });
const cached = textWidthCache.get(key);
if (cached) {
try {
const w = await cached;
return isValidWidth(w) ? { width: w, meta: { isApprox: false } } : { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
} catch {
if (__DEV__ && !loggedMeasureFailures.has(key)) {
loggedMeasureFailures.add(key);
console.log('[TextWrap][Measure] measureWidthCached: 命中缓存但 Promise 失败MEASURE_FAILED', {
context: args.context,
contextProfile: args.contextProfile,
fontSpec: args.fontSpec,
textPreview: text.slice(0, 80),
textLength: text.length,
});
}
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
}
}
const promise = (async () => {
try {
const w = await args.measureWidthImpl!({
text,
context: args.context,
contextProfile: args.contextProfile,
fontSpec,
});
if (!isValidWidth(w)) {
throw new Error('测量结果非法(必须为有限且非负的 number。');
}
return w;
} catch (error) {
if (__DEV__ && !loggedMeasureFailures.has(key)) {
loggedMeasureFailures.add(key);
console.log('[TextWrap][Measure] measureWidthImpl 抛错MEASURE_FAILED', {
context: args.context,
contextProfile: args.contextProfile,
fontSpec,
textPreview: text.slice(0, 80),
textLength: text.length,
errorName: (error as any)?.name,
errorMessage: String((error as any)?.message ?? error),
errorStack: (error as any)?.stack,
});
}
throw error;
}
})();
textWidthCache.set(key, promise);
try {
const w = await promise;
return { width: w, meta: { isApprox: false } };
} catch {
// 若失败,删除缓存,避免缓存住失败结果
textWidthCache.delete(key);
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
}
}

View File

@@ -0,0 +1,65 @@
import type { FontSpec, MeasureWidthImpl } from './types';
/**
* 默认测量实现成熟方案react-native-text-size
*
* 注意:
* - 该库的 `measure` 是 Promise 异步接口,本模块以 async 方式对齐。
* - 为避免在测试/Node 环境直接 require `react-native` 造成崩溃,这里使用“延迟加载 + 捕获异常”。
*/
type TextSizeMeasureParams = {
text: string;
width?: number;
fontFamily?: string;
fontSize?: number;
fontWeight?: string;
allowFontScaling?: boolean;
usePreciseWidth?: boolean;
};
type TextSizeMeasureResult = { width: number };
async function loadReactNativeTextSize(): Promise<{
measure: (params: TextSizeMeasureParams) => Promise<TextSizeMeasureResult>;
}> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import('react-native-text-size');
return mod?.default ?? mod;
}
function toTextSizeFontSpecs(fontSpec: FontSpec): Pick<TextSizeMeasureParams, 'fontFamily' | 'fontSize' | 'fontWeight'> {
return {
fontFamily: fontSpec.fontFamily,
fontSize: fontSpec.fontSize,
fontWeight: fontSpec.fontWeight,
};
}
/**
* 默认测量函数(可替换/可注入)。
* - width 约束设为极大值,避免自动换行影响“单行宽度”测量
* - usePreciseWidth=true取更精确的宽度开销更大但对本算法更稳定
*/
export const defaultMeasureWidthImpl: MeasureWidthImpl = async ({ text, fontSpec }) => {
const TextSize = await loadReactNativeTextSize();
if (!TextSize || typeof TextSize.measure !== 'function') {
// 典型原因:在 Expo Go 中运行,或没有使用包含该原生模块的 Development Build。
// 这里抛出更明确的错误,方便上层捕获并在 meta.reason=MEASURE_FAILED 时看到根因。
throw new Error(
[
'react-native-text-size 原生模块不可用TextSize.measure 不是函数。',
'请确认你不是在 Expo Go 里运行;需要使用包含该原生模块的 Development Buildexpo-dev-client / expo run:ios / EAS dev build。',
].join(' '),
);
}
const res = await TextSize.measure({
text,
width: 1_000_000_000,
usePreciseWidth: true,
allowFontScaling: true,
...toTextSizeFontSpecs(fontSpec),
});
return res.width;
};

View File

@@ -0,0 +1,48 @@
/**
* Text Wrap - width-measurement
*
* 本模块负责:
* - 宽度测量(可注入实现)
* - 两级缓存(文本/切片)
* - 测量失败时的确定性降级approx mode
*
* 注意:真实测量(如 react-native-text-size通常是异步 Promise本模块统一使用 async 形式对齐实际能力。
*/
export type TextWrapContext = 'APP' | 'WIDGET';
export type FontSpec = {
fontSize: number;
fontFamily: string;
fontWeight: string;
};
/**
* 用于区分不同测量语境(缓存隔离)。
* 口径建议:`APP|<platform>|<scale?>` 或 `WIDGET|<widgetSize?>`
*/
export type ContextProfile = string;
export type MeasureReason = 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
export type MeasureMeta = {
isApprox: boolean;
reason?: MeasureReason;
};
export type MeasureResult = {
width: number | null;
meta: MeasureMeta;
};
/**
* 可注入的测量实现(成熟方法推荐用原生/离屏测量库)。
* 约定:返回值必须是“有限且非负”的 number否则视为测量失败。
*/
export type MeasureWidthImpl = (args: {
text: string;
context: TextWrapContext;
fontSpec: FontSpec;
contextProfile: ContextProfile;
}) => Promise<number>;

View File

@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest';
import type { MeasureWidthImpl } from '../../measure/types';
import { applyOverflowFallback } from '../fallback';
function t(text: string, start: number) {
return { text, start, end: start + text.length };
}
describe('textWrap overflow-fallback', () => {
it('SYSTEM_DEFAULT仍返回单行但 meta 标记 SYSTEM_DEFAULT', async () => {
const res = await applyOverflowFallback({
tokens: [t('I', 0), t('am', 2), t('tired', 5)],
lang: 'EN',
context: 'APP',
availableWidth: 10,
maxLines: 2,
overflowMode: 'SYSTEM_DEFAULT',
ellipsisToken: '…',
reason: 'NO_CANDIDATE',
partialLayout: null,
});
expect(res.lines.length).toBe(1);
expect(res.meta.fallback_type).toBe('SYSTEM_DEFAULT');
expect(res.meta.overflow_type).toBe('NONE');
});
it('CLIP截断到 maxLines', async () => {
const res = await applyOverflowFallback({
tokens: [t('a', 0), t('b', 2), t('c', 4)],
lang: 'EN',
context: 'APP',
availableWidth: 10,
maxLines: 2,
overflowMode: 'CLIP',
ellipsisToken: '…',
reason: 'TOO_LONG',
partialLayout: {
breaks: [1, 2],
lines: [
{ start: 0, end: 1, text: 'a' },
{ start: 1, end: 2, text: 'b' },
// 造一个“未覆盖到 N”的 partial触发 overflow
{ start: 2, end: 2, text: 'c' },
],
},
});
expect(res.lines).toEqual(['a', 'b']);
expect(res.meta.overflow_type).toBe('CLIP');
});
it('ELLIPSIS清理末尾空白与 TC 末尾标点(不输出 \" …\" / \",…\"', async () => {
const res = await applyOverflowFallback({
tokens: [t('我', 0), t('好', 1), t('累', 2), t('', 3)],
lang: 'TC',
context: 'WIDGET',
availableWidth: 10,
maxLines: 1,
overflowMode: 'ELLIPSIS',
ellipsisToken: '…',
reason: 'NO_CANDIDATE',
partialLayout: {
breaks: [],
// 造一个“未覆盖到 N”的 partial触发 overflow
lines: [{ start: 0, end: 3, text: '我好累, ' }],
},
tcPunctuations: [''],
});
expect(res.lines[0]).toBe('我好累…');
expect(res.meta.overflow_type).toBe('ELLIPSIS');
});
it('ELLIPSIS可测量时超宽则按 token 回退直到不超宽', async () => {
// mock含省略号时宽度=100否则宽度=文本长度
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => (text.includes('…') ? 100 : text.length));
const res = await applyOverflowFallback({
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
lang: 'EN',
context: 'APP',
availableWidth: 10,
maxLines: 1,
overflowMode: 'ELLIPSIS',
ellipsisToken: '…',
reason: 'NO_CANDIDATE',
partialLayout: {
breaks: [],
// 造一个“未覆盖到 N”的 partial触发 overflow
lines: [{ start: 0, end: 3, text: 'I am so' }],
},
measure: {
context: 'APP',
contextProfile: 'APP|ios|ellipsis',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
},
});
// 因为任何带 ellipsis 的测量都超宽,这里会一路回退到空串 + ellipsis
expect(res.lines[0]).toBe('…');
expect(res.meta.overflow_type).toBe('ELLIPSIS');
});
});

View File

@@ -0,0 +1,108 @@
import type { Token } from '../core/types';
import { joinTokens } from '../core/joinTokens';
import { measureWidthCached } from '../measure/measureWidthCached';
import type { ApplyOverflowFallbackInput } from './types';
const DEFAULT_TC_PUNCTS = ['', '。', '', '', '', '', '、'];
export function cleanLineBeforeEllipsis(line: string, lang: 'TC' | 'EN', tcPunctuations?: string[]): string {
const s = String(line ?? '');
const trimmed = s.replace(/\s+$/g, '');
if (trimmed === '') return '';
if (lang === 'TC') {
const puncts = new Set(tcPunctuations && tcPunctuations.length > 0 ? tcPunctuations : DEFAULT_TC_PUNCTS);
const last = trimmed.slice(-1);
if (puncts.has(last)) return trimmed.slice(0, -1);
}
return trimmed;
}
async function measureIfPossible(args: {
text: string;
input: ApplyOverflowFallbackInput;
}): Promise<number | null> {
const { input, text } = args;
if (!input.measure) return null;
const enabled =
typeof input.measure.measureWidthImpl === 'function' &&
(input.measure.context === 'APP' || input.measure.widgetEnableMeasure === true);
if (!enabled) return null;
const res = await measureWidthCached({
text,
context: input.measure.context,
contextProfile: input.measure.contextProfile,
fontSpec: input.measure.fontSpec,
measureWidthImpl: input.measure.measureWidthImpl,
widgetEnableMeasure: input.measure.widgetEnableMeasure,
});
return res.width;
}
/**
* 给最后一行加省略号,并在可测量时保证不超宽:
* - EN 回退单位整词token
* - TC 回退单位graphemetoken
*/
export async function applyEllipsisToLastLine(args: {
input: ApplyOverflowFallbackInput;
baseLines: Array<{ start: number; end: number; text: string }>;
}): Promise<{ lines: string[] }> {
const { input } = args;
const maxLines = Math.max(1, input.maxLines | 0);
const lines = args.baseLines.slice(0, maxLines);
if (lines.length === 0) return { lines: [] };
const lastIdx = lines.length - 1;
const last = lines[lastIdx]!;
// 先按规则清理,再拼接 ellipsisToken
const cleaned = cleanLineBeforeEllipsis(last.text, input.lang, input.tcPunctuations);
let candidateText = `${cleaned}${input.ellipsisToken}`;
// 若无测量能力:直接输出(确定性)
const measured = await measureIfPossible({ text: candidateText, input });
if (measured === null) {
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
return { lines: out };
}
// 有测量能力:若超宽则按 token 回退
if (measured <= input.availableWidth) {
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
return { lines: out };
}
// 回退:逐步减少最后一行 token 数再加省略号
let end = last.end;
const start = last.start;
let foundFit = false;
while (end > start) {
end -= 1;
const base = joinTokens(input.tokens, start, end);
const cleaned2 = cleanLineBeforeEllipsis(base, input.lang, input.tcPunctuations);
const nextCandidate = `${cleaned2}${input.ellipsisToken}`;
const w = await measureIfPossible({ text: nextCandidate, input });
if (w !== null && w <= input.availableWidth) {
candidateText = nextCandidate;
foundFit = true;
break;
}
}
// 若连“仅省略号”都无法满足宽度(极窄场景),也必须给出确定性输出:直接输出 ellipsisToken
// 注意:这可能仍然超宽,但已是最小可表达形式;上层可通过 meta 做治理。
if (!foundFit) {
candidateText = input.ellipsisToken;
}
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
return { lines: out };
}

View File

@@ -0,0 +1,86 @@
import type { Token } from '../core/types';
import { joinTokens } from '../core/joinTokens';
import { applyEllipsisToLastLine } from './ellipsis';
import type { ApplyOverflowFallbackInput, ApplyOverflowFallbackResult, PartialLayoutInput, PartialLayoutLine } from './types';
function nTokens(tokens: Token[]): number {
return Array.isArray(tokens) ? tokens.length : 0;
}
function coversToEnd(partial: PartialLayoutInput | null | undefined, n: number): boolean {
if (!partial || !Array.isArray(partial.lines) || partial.lines.length === 0) return false;
const last = partial.lines[partial.lines.length - 1];
return (last?.end ?? -1) === n;
}
function buildLinesFromPartial(args: {
tokens: Token[];
partial?: PartialLayoutInput | null;
maxLines: number;
lang: 'TC' | 'EN';
}): Array<{ start: number; end: number; text: string }> {
const N = nTokens(args.tokens);
const maxLines = Math.max(1, args.maxLines | 0);
const rawSeparators = args.lang === 'TC' ? Array.from({ length: Math.max(0, N) }, () => '') : undefined;
if (args.partial && Array.isArray(args.partial.lines) && args.partial.lines.length > 0) {
const out: Array<{ start: number; end: number; text: string }> = [];
for (const line of args.partial.lines.slice(0, maxLines)) {
const start = Math.max(0, line.start | 0);
const end = Math.min(N, Math.max(start, line.end | 0));
const text = line.text ?? joinTokens(args.tokens, start, end, rawSeparators);
out.push({ start, end, text });
}
return out;
}
// 若没有 partial把全文当单行 best-effort
return [{ start: 0, end: N, text: joinTokens(args.tokens, 0, N, rawSeparators) }];
}
function toWrapped(lines: string[]): { lines: string[]; wrappedText: string } {
const outLines = lines.filter((s) => s !== undefined) as string[];
return { lines: outLines, wrappedText: outLines.join('\n') };
}
function toSingleLine(tokens: Token[]): { lines: string[]; wrappedText: string } {
const N = nTokens(tokens);
// 注意:这里不知道语言口径,因此 SYSTEM_DEFAULT 的单行文本在上层保证传入“原文”更稳妥。
// 为保持最小可用,这里仍使用默认 joinTokensEN=空格 joinTC=grapheme 之间可能会有空格)。
const text = joinTokens(tokens, 0, N);
return { lines: [text], wrappedText: text };
}
export async function applyOverflowFallback(input: ApplyOverflowFallbackInput): Promise<ApplyOverflowFallbackResult> {
const tokens = input.tokens ?? [];
const N = nTokens(tokens);
const maxLines = Math.max(1, input.maxLines | 0);
const isOverflow = !coversToEnd(input.partialLayout, N);
// 若其实没有 overflow直接返回NONE
if (!isOverflow) {
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
const { lines, wrappedText } = toWrapped(base.map((l) => l.text));
return { lines, wrappedText, meta: { fallback_type: 'NONE', overflow_type: 'NONE', reason: input.reason } };
}
if (input.overflowMode === 'SYSTEM_DEFAULT') {
const { lines, wrappedText } = toSingleLine(tokens);
return { lines, wrappedText, meta: { fallback_type: 'SYSTEM_DEFAULT', overflow_type: 'NONE', reason: input.reason } };
}
if (input.overflowMode === 'CLIP') {
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
const { lines, wrappedText } = toWrapped(base.slice(0, maxLines).map((l) => l.text));
return { lines, wrappedText, meta: { fallback_type: 'NONE', overflow_type: 'CLIP', reason: input.reason } };
}
// ELLIPSIS
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
const { lines } = await applyEllipsisToLastLine({ input, baseLines: base.slice(0, maxLines) });
const wrapped = toWrapped(lines);
return { ...wrapped, meta: { fallback_type: 'NONE', overflow_type: 'ELLIPSIS', reason: input.reason } };
}

View File

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

View File

@@ -0,0 +1,47 @@
import type { Token } from '../core/types';
import type { ContextProfile, FontSpec, MeasureWidthImpl, TextWrapContext } from '../measure/types';
export type OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT';
export type FallbackType = 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT';
export type OverflowType = 'NONE' | 'ELLIPSIS' | 'CLIP';
export type OverflowReason = 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'TOO_LONG' | 'WIDOW' | 'PARTICLE' | string;
export type PartialLayoutLine = { start: number; end: number; text?: string };
export type PartialLayoutInput = { breaks: number[]; lines: PartialLayoutLine[] };
export type OverflowMeasure = {
context: TextWrapContext;
contextProfile: ContextProfile;
fontSpec: Partial<FontSpec> | null | undefined;
measureWidthImpl?: MeasureWidthImpl;
widgetEnableMeasure?: boolean;
};
export type ApplyOverflowFallbackInput = {
tokens: Token[];
lang: 'TC' | 'EN';
context: TextWrapContext;
availableWidth: number;
maxLines: number;
overflowMode: OverflowMode;
ellipsisToken: string;
reason: OverflowReason;
partialLayout?: PartialLayoutInput | null;
measure?: OverflowMeasure;
/** TC 标点集合(用于清理 `,…` */
tcPunctuations?: string[];
};
export type WrapTextMeta = {
fallback_type: FallbackType;
overflow_type: OverflowType;
reason: OverflowReason;
};
export type ApplyOverflowFallbackResult = {
lines: string[];
wrappedText: string;
meta: WrapTextMeta;
};

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest';
import type { Token } from '../../core/types';
import { buildTieKey, DEFAULT_LEXICONS, DEFAULT_WEIGHTS, scoreLayout } from '../index';
function t(text: string, start: number): Token {
return { text, start, end: start + text.length };
}
function lexicographicLess(a: Array<number | string>, b: Array<number | string>): boolean {
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) {
const av = a[i] as any;
const bv = b[i] as any;
if (av < bv) return true;
if (av > bv) return false;
}
return a.length < b.length;
}
describe('textWrap scoring-tiebreak', () => {
it('EMOTION_SPLIT拆分情绪短语 -> 强惩罚 + 追加 spanLen方案 A', () => {
const tokens: Token[] = [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)];
const layoutCandidate = {
breaks: [3], // I am so | tired
lines: [
{ start: 0, end: 3, text: 'I am so', width: 10, tokenCount: 3, charCount: 0 },
{ start: 3, end: 4, text: 'tired', width: 10, tokenCount: 1, charCount: 0 },
],
};
const res = scoreLayout({
tokens,
layoutCandidate,
lang: 'EN',
context: 'APP',
availableWidth: 100,
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
lexicons: { ...DEFAULT_LEXICONS, emotionPhrasesEN: ['so tired'] },
debug: true,
});
// 触发强惩罚:-(P_EMOTION_SPLIT + spanLen)
const first = res.scoreBreakdown?.terms[0];
expect(first?.key).toBe('EMOTION_SPLIT');
expect(first?.delta).toBe(-(DEFAULT_WEIGHTS.P_EMOTION_SPLIT + 2));
expect(res.flags.emotionSplit).toBe(true);
});
it('Shift/Accum/SelfEN 词两端带标点也应命中(全词等值匹配)', () => {
const tokens: Token[] = [t('but,', 0), t('still', 5), t('ok', 11)];
const layoutCandidate = {
breaks: [1], // but, | still ok
lines: [
// 把 width 设置到 idealWidth 上,避免 length 项干扰 debug Top-3
{ start: 0, end: 1, text: 'but,', width: 90, tokenCount: 1, charCount: 0 },
{ start: 1, end: 3, text: 'still ok', width: 90, tokenCount: 2, charCount: 0 },
],
};
const res = scoreLayout({
tokens,
layoutCandidate,
lang: 'EN',
context: 'APP',
availableWidth: 100, // idealWidth=100*0.9=90
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
lexicons: DEFAULT_LEXICONS,
debug: true,
});
const terms = res.scoreBreakdown?.terms ?? [];
expect(terms.some((x) => x.key === 'SHIFT_BREAK')).toBe(true);
expect(terms.some((x) => x.key === 'ACCUM_BREAK')).toBe(true);
});
it('10.2GEmotionWord 与 Accum 同时命中 -> Accum 奖励减半(整数)', () => {
const tokens: Token[] = [t('already', 0), t('tired', 8)];
const layoutCandidate = {
breaks: [1], // already | tired
lines: [
{ start: 0, end: 1, text: 'already', width: 90, tokenCount: 1, charCount: 0 },
{ start: 1, end: 2, text: 'tired', width: 90, tokenCount: 1, charCount: 0 },
],
};
const weights = { ...DEFAULT_WEIGHTS, R_EMOTION_TAIL: 10, R_ACCUM_BREAK: 41 }; // 41/2 -> 20floor
const res = scoreLayout({
tokens,
layoutCandidate,
lang: 'EN',
context: 'APP',
availableWidth: 100,
config: { weights, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
lexicons: DEFAULT_LEXICONS,
debug: true,
});
const terms = res.scoreBreakdown?.terms ?? [];
// debug 可能截断为 Top-3但应包含 EMOTION_TAIL 与 ACCUM_BREAK顺序确定性
const hasEmotion = terms.some((x) => x.key === 'EMOTION_TAIL' && x.delta === 10);
const hasAccumHalf = terms.some((x) => x.key === 'ACCUM_BREAK' && x.delta === Math.floor(41 / 2));
expect(hasEmotion).toBe(true);
expect(hasAccumHalf).toBe(true);
});
it('tieKeylastLineWidth 更大优先(取 -lastLineWidth', () => {
const base = {
breaks: [2],
lines: [
{ start: 0, end: 2, text: 'a b', width: 50, tokenCount: 2, charCount: 0 },
{ start: 2, end: 3, text: 'c', width: 10, tokenCount: 1, charCount: 0 },
],
};
const scoredA = { score: 0, flags: {}, tieKey: [] as any };
const scoredB = { score: 0, flags: {}, tieKey: [] as any };
const keyShort = buildTieKey({
scoredLayout: scoredA as any,
layoutCandidate: base as any,
lang: 'EN',
context: 'APP',
tokenCount: 3,
availableWidth: 100,
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
});
const layoutLongTail = {
...base,
lines: [
base.lines[0]!,
{ ...base.lines[1]!, width: 30 }, // lastLineWidth 更大
],
};
const keyLong = buildTieKey({
scoredLayout: scoredB as any,
layoutCandidate: layoutLongTail as any,
lang: 'EN',
context: 'APP',
tokenCount: 3,
availableWidth: 100,
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
});
// 更优的 tieKey 应“更小”(因为 lastLineWidth 取负)
expect(lexicographicLess(keyLong, keyShort)).toBe(true);
});
});

View File

@@ -0,0 +1,21 @@
export type {
LayoutCandidate,
LayoutCandidateLine,
Lexicons,
ScoredLayout,
ScoreBreakdown,
ScoreLayoutInput,
ScoreTerm,
ScoringConfig,
TextWrapContext,
Weights,
} from './types';
export { DEFAULT_LEXICONS, mergeLexicons } from './lexicons';
export { DEFAULT_WEIGHTS, mergeWeights } from './weights';
export { preparePhraseTokens, findPhraseSpans, isSpanSplitByBreaks } from './phraseMatch';
export { scoreLayout } from './score';
export type { BuildTieKeyInput } from './tieKey';
export { buildTieKey } from './tieKey';

View File

@@ -0,0 +1,31 @@
import type { Lexicons } from './types';
/**
* scoring-tiebreak 最小词表(首版写死客户端)
*
* 来源:`设计说明文档/文档换行算法.md v1.2.1` 第 7 节
* 原则:少而准(后续通过打点迭代扩充)
*/
export const DEFAULT_LEXICONS: Readonly<Lexicons> = Object.freeze({
emotionPhrasesTC: [],
emotionPhrasesEN: [],
protectedPhrases: [],
shiftWordsTC: ['但', '可是', '然而', '却', '只是', '偏偏'],
shiftWordsEN: ['but', 'yet', 'so'],
accumWordsTC: ['已经', '一直', '曾经', '终于', '还是', '到现在'],
accumWordsEN: ['already', 'still', 'even', 'just', 'really'],
selfWordsTC: ['你', '我', '自己', '我们', '别人'],
selfWordsEN: ['you', 'yourself', 'me', 'we'],
emotionWordsTC: ['累', '痛', '怕', '孤单', '委屈', '撑', '崩溃', '放弃'],
emotionWordsEN: ['tired', 'afraid', 'lonely', 'hurt', 'overwhelmed', 'give up'],
});
export function mergeLexicons(overrides?: Partial<Lexicons> | null | undefined): Lexicons {
if (!overrides) return { ...DEFAULT_LEXICONS };
return { ...DEFAULT_LEXICONS, ...overrides };
}

View File

@@ -0,0 +1,92 @@
import type { Lang, Token } from '../core/types';
import { matchENKeyword, normalizeENKeyword, normalizeWhitespace, tokenizeEN } from '../core/index';
import { segmentGraphemes } from '../grapheme/index';
export type PhraseSpan = { start: number; end: number; length: number };
function toNonEmptyArray(parts: string[]): string[] {
return parts.map((s) => s.trim()).filter((s) => s.length > 0);
}
export function preparePhraseTokens(phrase: string, lang: Lang): string[] {
const input = String(phrase ?? '');
if (input === '') return [];
if (lang === 'EN') {
const { normalizedText } = normalizeWhitespace(input, 'NORMALIZE');
const words = normalizedText.split(' ');
// EN按“全词等值匹配”口径做归一化小写 + 两端去常见标点)
return toNonEmptyArray(words).map((w) => normalizeENKeyword(w));
}
// TC按 grapheme clusters
const { clusters } = segmentGraphemes(input, 'PREFERRED');
return clusters;
}
function tokenEqualsPhraseToken(tokenText: string, phraseToken: string, lang: Lang): boolean {
if (lang === 'EN') return matchENKeyword(tokenText, phraseToken);
return tokenText === phraseToken;
}
/**
* 在 tokens 中寻找 phraseTokens 的“连续区间完全匹配”(必须)。
*
* 输出顺序(确定性):
* - start 升序
* - start 相同length 降序(更长优先)
*/
export function findPhraseSpans(tokens: Token[], phraseTokens: string[], lang: Lang): PhraseSpan[] {
const n = tokens.length;
const m = phraseTokens.length;
if (n === 0 || m === 0 || m > n) return [];
const spans: PhraseSpan[] = [];
for (let i = 0; i <= n - m; i++) {
let ok = true;
for (let j = 0; j < m; j++) {
const t = tokens[i + j]?.text ?? '';
const p = phraseTokens[j] ?? '';
if (!tokenEqualsPhraseToken(t, p, lang)) {
ok = false;
break;
}
}
if (ok) spans.push({ start: i, end: i + m, length: m });
}
spans.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start;
return b.length - a.length;
});
return spans;
}
/**
* 判断某个 span 是否被断点拆分到不同的行span 内存在 break
*
* 说明:
* - span=[start,end) 是 token 索引区间
* - breaks[i] 是 token 边界索引1..N-1
* - 若存在 start < b < end则 span 被拆分
*/
export function isSpanSplitByBreaks(span: PhraseSpan, breaks: number[]): boolean {
const s = span.start;
const e = span.end;
if (e - s <= 1) return false;
for (const b of breaks) {
if (b > s && b < e) return true;
if (b >= e) break; // breaks 升序:可提前退出
}
return false;
}
/**
* 仅用于测试:把 EN 文本按 core-contract 口径 tokenize。
*/
export function tokenizePhraseLikeInputEN(text: string): Token[] {
const { normalizedText } = normalizeWhitespace(text, 'NORMALIZE');
return tokenizeEN(normalizedText);
}

View File

@@ -0,0 +1,367 @@
import type { Lang, Token } from '../core/types';
import { normalizeENKeyword } from '../core/index';
import type { LayoutCandidateLine, ScoreLayoutInput, ScoreTerm, ScoredLayout, Weights } from './types';
import { findPhraseSpans, isSpanSplitByBreaks, preparePhraseTokens } from './phraseMatch';
import { mergeLexicons } from './lexicons';
import { mergeWeights } from './weights';
function asInt(n: number): number {
// 保证输出整数(避免后续误用浮点)
return n | 0;
}
function absInt(n: number): number {
return n < 0 ? -n : n;
}
function roundToInt(n: number): number {
return Math.round(n);
}
function ratioToBp(ratio: number): number {
// 把 0.90 转为 900bp=1/1000用于尽量规避浮点带来的跨端差异
return Math.round(ratio * 1000);
}
function lineWidthOrApprox(line: LayoutCandidateLine, lang: Lang): number {
if (typeof line.width === 'number' && Number.isFinite(line.width)) return roundToInt(line.width);
// width unknown/approx回退到 tokenCount / charCount确定性
return lang === 'EN' ? asInt(line.tokenCount) : asInt(line.charCount);
}
function buildSet(arr: string[] | undefined, lang: Lang): Set<string> {
if (!arr || arr.length === 0) return new Set();
if (lang === 'EN') return new Set(arr.map((w) => normalizeENKeyword(w)));
return new Set(arr);
}
function tokenHitSet(tokenText: string, set: Set<string>, lang: Lang): boolean {
if (set.size === 0) return false;
if (lang === 'EN') {
// 全词等值匹配允许两端标点包裹normalize 后做等值命中即可
return set.has(normalizeENKeyword(tokenText));
}
return set.has(tokenText);
}
function isTcPunctuation(t: string, punctSet: Set<string>): boolean {
return punctSet.has(t);
}
function computeEmotionTailFlags(tokens: Token[], lines: LayoutCandidateLine[], lang: Lang, emotionWordsSet: Set<string>): boolean[] {
const flags: boolean[] = [];
if (emotionWordsSet.size === 0) return lines.map(() => false);
for (const line of lines) {
const start = Math.max(0, line.start | 0);
const end = Math.max(start, line.end | 0);
const slice = tokens.slice(start, end);
if (slice.length === 0) {
flags.push(false);
continue;
}
if (lang === 'EN') {
const last = slice[slice.length - 1]?.text ?? '';
flags.push(tokenHitSet(last, emotionWordsSet, 'EN'));
continue;
}
// TC“最后 2 个 grapheme 范围内命中”
const last1 = slice[slice.length - 1]?.text ?? '';
const last2 = slice.length >= 2 ? slice[slice.length - 2]?.text ?? '' : '';
flags.push(tokenHitSet(last1, emotionWordsSet, 'TC') || tokenHitSet(last2, emotionWordsSet, 'TC'));
}
return flags;
}
function limitBreakdownForDebug(terms: ScoreTerm[]): ScoreTerm[] {
// 裁决补充 10.4Adebug Top-3 按规则优先级10.1 顺序)输出
// 这里采用最稳妥的实现:直接取“按实现顺序生成的前 3 项”(确定性且满足优先级排序)
return terms.slice(0, 3);
}
export function scoreLayout(input: ScoreLayoutInput): ScoredLayout {
const weights: Weights = mergeWeights(input.config.weights);
const lexicons = mergeLexicons(input.lexicons);
const tokens = input.tokens ?? [];
const breaks = (input.layoutCandidate.breaks ?? []).slice().sort((a, b) => a - b);
const lines = input.layoutCandidate.lines ?? [];
const terms: ScoreTerm[] = [];
let score = 0;
const flags: ScoredLayout['flags'] = {
overflowed: Boolean(input.layoutCandidate.meta?.overflowed),
fallback: Boolean(input.layoutCandidate.meta?.fallback),
};
// ------------------------------
// 10.1-1 情绪短语拆分惩罚(最高优先)
// ------------------------------
{
let deltaEmotion = 0;
let deltaProtected = 0;
const listEmotion = input.lang === 'EN' ? lexicons.emotionPhrasesEN : lexicons.emotionPhrasesTC;
const listProtected = lexicons.protectedPhrases ?? [];
const pushSplit = (key: 'EMOTION_SPLIT' | 'PROTECTED_SPLIT', phrase: string, spanLen: number) => {
const base = key === 'EMOTION_SPLIT' ? weights.P_EMOTION_SPLIT : weights.P_PROTECTED_SPLIT;
const d = -asInt(base) - asInt(spanLen); // 方案 A追加“被拆分短语长度”惩罚更长短语更强保护
if (key === 'EMOTION_SPLIT') deltaEmotion += d;
else deltaProtected += d;
flags.emotionSplit = true;
// 记录 detail 便于治理
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
terms.push({ key, delta: d, detail: { phrase, spanLen } });
};
// emotion phrases
for (const phrase of listEmotion) {
const phraseTokens = preparePhraseTokens(phrase, input.lang);
const spans = findPhraseSpans(tokens, phraseTokens, input.lang);
for (const span of spans) {
if (isSpanSplitByBreaks(span, breaks)) pushSplit('EMOTION_SPLIT', phrase, span.length);
}
}
// protected phrases
for (const phrase of listProtected) {
const phraseTokens = preparePhraseTokens(phrase, input.lang);
const spans = findPhraseSpans(tokens, phraseTokens, input.lang);
for (const span of spans) {
if (isSpanSplitByBreaks(span, breaks)) pushSplit('PROTECTED_SPLIT', phrase, span.length);
}
}
// 注意:这里已经把 term 推入了 terms保持“优先级最高的项”最先出现
score += deltaEmotion + deltaProtected;
}
// ------------------------------
// 10.1-2 行超长惩罚 + 理想长度奖励
// ------------------------------
{
const idealBp = ratioToBp(input.config.idealWidthRatio[input.context]);
const idealWidth = roundToInt((input.availableWidth * idealBp) / 1000);
const minPreferredRatio = input.config.minPreferredRatio ?? 0.6;
const minPreferredWidth = roundToInt((idealWidth * ratioToBp(minPreferredRatio)) / 1000);
let overMaxDelta = 0;
let idealDelta = 0;
let tooShortDelta = 0;
for (const line of lines) {
const w = lineWidthOrApprox(line, input.lang);
if (w > input.availableWidth) {
overMaxDelta -= asInt(weights.P_OVER_MAXLEN) * asInt(w - input.availableWidth);
}
idealDelta -= absInt(w - idealWidth);
if (w < minPreferredWidth) {
tooShortDelta -= asInt(weights.P_TOO_SHORT) * asInt(minPreferredWidth - w);
}
}
if (overMaxDelta !== 0) terms.push({ key: 'OVER_MAXLEN', delta: overMaxDelta, detail: { availableWidth: input.availableWidth } });
if (idealDelta !== 0) terms.push({ key: 'IDEAL_LEN', delta: idealDelta, detail: { idealWidth, idealBp } });
if (tooShortDelta !== 0) terms.push({ key: 'TOO_SHORT', delta: tooShortDelta, detail: { minPreferredWidth } });
score += overMaxDelta + idealDelta + tooShortDelta;
}
// ------------------------------
// 10.1-3 widowEN/ 单字行TC
// ------------------------------
{
const lastLine = lines[lines.length - 1];
if (lastLine) {
const lastWidth = lineWidthOrApprox(lastLine, input.lang);
if (input.lang === 'EN') {
const tokenCount = asInt(lastLine.tokenCount);
if (tokenCount === 1) {
const d = -asInt(weights.P_WIDOW_LINE);
terms.push({ key: 'WIDOW_LINE', delta: d });
score += d;
const start = Math.max(0, lastLine.start | 0);
const end = Math.max(start, lastLine.end | 0);
const only = tokens.slice(start, end)[0]?.text ?? '';
const norm = normalizeENKeyword(only);
const widowMaxLen = asInt(input.config.widowMaxLen ?? 3);
if (norm.length > 0 && norm.length <= widowMaxLen) {
const d2 = -asInt(weights.P_WIDOW_WORD);
terms.push({ key: 'WIDOW_WORD', delta: d2, detail: { word: norm, widowMaxLen } });
score += d2;
}
}
} else {
// TC最后一行只有 1 个 grapheme或 charCount<=1视为“单字行”
const c = asInt(lastLine.charCount);
if (c <= 1) {
const d = -asInt(weights.P_WIDOW_LINE);
terms.push({ key: 'WIDOW_LINE', delta: d, detail: { tcSingleChar: true, lastWidth } });
score += d;
}
}
}
}
// ------------------------------
// 10.2F TC 助词孤立(在 10.1-3 后落地,保证顺序确定性)
// ------------------------------
if (input.lang === 'TC' && lines.length > 0) {
const particles = new Set(input.config.tcParticles ?? ['啊', '喔', '呢', '啦', '吗', '吧', '呀']);
const whitelist = new Set(input.config.tcParticleWhitelist ?? ['啊', '喔', '呢', '啦']);
let delta = 0;
for (const line of lines) {
const start = Math.max(0, line.start | 0);
const end = Math.max(start, line.end | 0);
const slice = tokens.slice(start, end);
if (slice.length === 0) continue;
const first = slice[0]?.text ?? '';
const last = slice[slice.length - 1]?.text ?? '';
const hitFirst = particles.has(first);
const hitLast = particles.has(last);
if (!hitFirst && !hitLast) continue;
const base = asInt(weights.P_PARTICLE_ISO);
const half = Math.floor(base / 2);
if (hitFirst) delta -= whitelist.has(first) ? half : base;
if (hitLast) delta -= whitelist.has(last) ? half : base;
}
if (delta !== 0) {
terms.push({ key: 'PARTICLE_ISO', delta, detail: { particles: Array.from(particles), whitelist: Array.from(whitelist) } });
score += delta;
}
}
// ------------------------------
// 10.1-4 标点断点奖励TC
// ------------------------------
if (input.lang === 'TC') {
const puncts = new Set(input.config.tcPunctuations ?? ['', '。', '', '', '', '', '、']);
let delta = 0;
for (const b of breaks) {
const prev = tokens[b - 1]?.text ?? '';
if (isTcPunctuation(prev, puncts)) delta += asInt(weights.R_PUNCT_BREAK);
}
if (delta !== 0) {
terms.push({ key: 'PUNCT_BREAK', delta, detail: { tcPunctuations: Array.from(puncts) } });
score += delta;
}
}
// ------------------------------
// 10.1-5 Shift/Accum/Self 断点奖励(含 EN 行首/行尾感知)
// ------------------------------
{
const shiftSet = buildSet(input.lang === 'EN' ? lexicons.shiftWordsEN : lexicons.shiftWordsTC, input.lang);
const accumSet = buildSet(input.lang === 'EN' ? lexicons.accumWordsEN : lexicons.accumWordsTC, input.lang);
const selfSet = buildSet(input.lang === 'EN' ? lexicons.selfWordsEN : lexicons.selfWordsTC, input.lang);
const emotionSet = buildSet(input.lang === 'EN' ? lexicons.emotionWordsEN : lexicons.emotionWordsTC, input.lang);
const emotionTailByLine = computeEmotionTailFlags(tokens, lines, input.lang, emotionSet);
// EmotionWord 落点强化(默认权重为 0不影响结果但用于实现 10.2G 的“Accum 减半”规则)
let emotionTailDelta = 0;
for (let i = 0; i < emotionTailByLine.length; i++) {
if (emotionTailByLine[i]) emotionTailDelta += asInt(weights.R_EMOTION_TAIL);
}
if (emotionTailDelta !== 0) {
terms.push({ key: 'EMOTION_TAIL', delta: emotionTailDelta });
score += emotionTailDelta;
}
let shiftDelta = 0;
let accumDelta = 0;
let selfDelta = 0;
// 为 Accum 减半规则准备:把每个 break 映射到其左右行索引
// 假设 lines 与 breaks 一一对应breaks[i] 分隔 lines[i] 与 lines[i+1]
for (let i = 0; i < breaks.length; i++) {
const b = breaks[i]!;
const prevLast = tokens[b - 1]?.text ?? '';
const nextFirst = tokens[b]?.text ?? '';
const hitShift = tokenHitSet(nextFirst, shiftSet, input.lang) || tokenHitSet(prevLast, shiftSet, input.lang);
const hitAccum = tokenHitSet(nextFirst, accumSet, input.lang) || tokenHitSet(prevLast, accumSet, input.lang);
const hitSelf = tokenHitSet(nextFirst, selfSet, input.lang) || tokenHitSet(prevLast, selfSet, input.lang);
if (hitShift) shiftDelta += asInt(weights.R_SHIFT_BREAK);
if (hitSelf) selfDelta += asInt(weights.R_SELF_BREAK);
if (hitAccum) {
// 10.2G:若同一行(或同一断点)同时满足 EmotionWord 强化与 Accum 奖励 -> Accum 减半
const leftLineIdx = i;
const rightLineIdx = i + 1;
const emotionHitNearby = Boolean(emotionTailByLine[leftLineIdx]) || Boolean(emotionTailByLine[rightLineIdx]);
const base = asInt(weights.R_ACCUM_BREAK);
const reward = emotionHitNearby ? Math.floor(base / 2) : base;
accumDelta += reward;
}
}
if (shiftDelta !== 0) {
terms.push({ key: 'SHIFT_BREAK', delta: shiftDelta });
score += shiftDelta;
}
if (accumDelta !== 0) {
terms.push({ key: 'ACCUM_BREAK', delta: accumDelta });
score += accumDelta;
}
if (selfDelta !== 0) {
terms.push({ key: 'SELF_BREAK', delta: selfDelta });
score += selfDelta;
}
}
// ------------------------------
// 10.1-6 多行视觉均衡(尾行过短惩罚)
// ------------------------------
{
const lastLine = lines[lines.length - 1];
if (lastLine) {
const idealBp = ratioToBp(input.config.idealWidthRatio[input.context]);
const idealWidth = roundToInt((input.availableWidth * idealBp) / 1000);
const shortRatio = input.config.shortLastLineRatio ?? 0.5;
const shortThreshold = roundToInt((idealWidth * ratioToBp(shortRatio)) / 1000);
const lastW = lineWidthOrApprox(lastLine, input.lang);
if (lastW < shortThreshold) {
const d = -asInt(weights.P_SHORT_LASTLINE);
terms.push({ key: 'SHORT_LASTLINE', delta: d, detail: { shortThreshold, lastW, idealWidth } });
score += d;
}
}
}
const scoreBreakdown = input.debug ? { total: score, terms: limitBreakdownForDebug(terms) } : undefined;
// tieKey 在 tieKey.ts 单独构造(此处返回占位,调用方可覆盖)
const tieKey: Array<number | string> = [
flags.emotionSplit ? 1 : 0,
flags.overflowed ? 1 : 0,
// lastLineWidth/spread/idealDistance 等由 buildTieKey 计算;这里仅保证结构存在
];
return { score, flags, tieKey, scoreBreakdown };
}

View File

@@ -0,0 +1,96 @@
import type { Lang } from '../core/types';
import type { LayoutCandidate, LayoutCandidateLine, ScoredLayout, TextWrapContext } from './types';
function asInt(n: number): number {
return n | 0;
}
function roundToInt(n: number): number {
return Math.round(n);
}
function lineWidthOrApprox(line: LayoutCandidateLine, lang: Lang): number {
if (typeof line.width === 'number' && Number.isFinite(line.width)) return roundToInt(line.width);
return lang === 'EN' ? asInt(line.tokenCount) : asInt(line.charCount);
}
function computeIdealBreakPositions(nTokens: number, lineCount: number): number[] {
const n = Math.max(0, nTokens | 0);
const targetLines = Math.max(1, Math.min(lineCount | 0, n === 0 ? 1 : n));
const ideals: number[] = [];
for (let i = 1; i <= targetLines - 1; i++) {
ideals.push(Math.round((n * i) / targetLines));
}
ideals.sort((a, b) => a - b);
return ideals.filter((v, idx) => idx === 0 || v !== ideals[idx - 1]);
}
function sumAbs(a: number[], b: number[]): number {
const m = Math.min(a.length, b.length);
let s = 0;
for (let i = 0; i < m; i++) s += Math.abs((a[i] ?? 0) - (b[i] ?? 0));
// 若长度不等,用一个确定性惩罚补齐(避免 NaN并让更“匹配理想结构”的更优
if (a.length !== b.length) s += 100000 * Math.abs(a.length - b.length);
return s;
}
function computeSpread(widths: number[]): number {
if (widths.length === 0) return 0;
let min = widths[0]!;
let max = widths[0]!;
for (const w of widths) {
if (w < min) min = w;
if (w > max) max = w;
}
return max - min;
}
export type BuildTieKeyInput = {
scoredLayout: ScoredLayout;
layoutCandidate: LayoutCandidate;
lang: Lang;
context: TextWrapContext;
/** tokens.length用于理想切分点距离必须与 breaks/lines 的 token 体系一致) */
tokenCount: number;
/** 可用宽度(用于理想切分点/尾行宽度判断的辅助项tieKey 主要使用 width 值) */
availableWidth: number;
idealWidthRatio: { APP: number; WIDGET: number };
};
/**
* 构造 tieKey按文档 11 节顺序,必须确定性):
* 1) emotionSplit=false 优先
* 2) overflowed=false 优先
* 3) lastLineWidth 更大优先
* 4) 行宽分布更均匀优先max-min 更小)
* 5) 断点更接近理想切分点优先(距离之和更小)
* 6) breaks 字典序更靠前优先(在外部比较中按逐项数值比较即可)
*/
export function buildTieKey(args: BuildTieKeyInput): Array<number | string> {
const { scoredLayout, layoutCandidate } = args;
const lines = layoutCandidate.lines ?? [];
const breaks = (layoutCandidate.breaks ?? []).slice().sort((a, b) => a - b);
const widths = lines.map((l) => lineWidthOrApprox(l, args.lang));
const lastLineWidth = widths.length > 0 ? widths[widths.length - 1]! : 0;
const spread = computeSpread(widths);
const ideals = computeIdealBreakPositions(args.tokenCount, lines.length);
const idealDist = sumAbs(breaks, ideals);
const emotionSplitFlag = scoredLayout.flags.emotionSplit ? 1 : 0;
const overflowedFlag = scoredLayout.flags.overflowed ? 1 : 0;
return [
emotionSplitFlag,
overflowedFlag,
-lastLineWidth, // 更大优先 => 取负数实现“更小更优”
spread, // 更小更优
idealDist, // 更小更优
...breaks,
];
}

View File

@@ -0,0 +1,92 @@
import type { Lang, Token } from '../core/types';
export type TextWrapContext = 'APP' | 'WIDGET';
export type LayoutCandidateLine = {
/**
* 该行对应的 token 区间(半开区间),单位与 `breaks` 一致:
* - start包含
* - end不包含
*/
start: number;
end: number;
/** 该行展示文本(用于 debug评分以 token/width 为主) */
text: string;
/**
* 该行宽度(同一候选集必须同一单位)。
* - App像素等真实测量单位
* - Widget approx可使用近似单位例如 wordCount/graphemeCount
*/
width: number | null;
/** 该行 token 数EN=词数TC=grapheme 数) */
tokenCount: number;
/** 该行字符簇数TC 使用EN 可等于 tokenCount 或 0 */
charCount: number;
};
export type LayoutCandidate = {
/** 断点序列token 边界索引),升序 */
breaks: number[];
/** 每行信息lines.length = breaks.length + 1 */
lines: LayoutCandidateLine[];
/** 可选外部模块overflow/fallback可透传的标记 */
meta?: { overflowed?: boolean; fallback?: boolean };
};
export type ScoreTerm = { key: string; delta: number; detail?: any };
export type ScoreBreakdown = { total: number; terms: ScoreTerm[] };
export type ScoredLayout = {
score: number;
flags: { emotionSplit?: boolean; overflowed?: boolean; fallback?: boolean };
tieKey: Array<number | string>;
scoreBreakdown?: ScoreBreakdown;
};
export type Weights = Record<string, number>;
export type Lexicons = {
emotionPhrasesTC: string[];
emotionPhrasesEN: string[];
protectedPhrases?: string[];
shiftWordsTC: string[];
shiftWordsEN: string[];
accumWordsTC: string[];
accumWordsEN: string[];
selfWordsTC: string[];
selfWordsEN: string[];
emotionWordsTC?: string[];
emotionWordsEN?: string[];
};
export type ScoringConfig = {
weights: Weights;
idealWidthRatio: { APP: number; WIDGET: number };
ellipsisToken: string;
/** TC 标点集合(用于 PUNCT_BREAK 奖励与行首标点检测) */
tcPunctuations?: string[];
/** TC 助词集合(用于 PARTICLE_ISO */
tcParticles?: string[];
/** TC 语尾语助词白名单:触发 PARTICLE_ISO 时惩罚减半 */
tcParticleWhitelist: string[];
/** Widow 短词阈值ENlen(word) <= widowMaxLen默认 3 */
widowMaxLen?: number;
/** 过短阈值minPreferred = idealWidth * minPreferredRatio默认 0.6 */
minPreferredRatio?: number;
/** 尾行过短阈值shortLastLine = idealWidth * shortLastLineRatio默认 0.5 */
shortLastLineRatio?: number;
};
export type ScoreLayoutInput = {
tokens: Token[];
layoutCandidate: LayoutCandidate;
lang: Lang;
context: TextWrapContext;
/** 可用宽度(同 width 单位),必须由上游提供 */
availableWidth: number;
config: ScoringConfig;
lexicons: Lexicons;
debug?: boolean;
};

View File

@@ -0,0 +1,35 @@
import type { Weights } from './types';
/**
* scoring-tiebreak 默认权重(首版写死客户端)
*
* 来源:`设计说明文档/文档换行算法.md v1.2.1` 10.3
*/
export const DEFAULT_WEIGHTS = Object.freeze({
P_EMOTION_SPLIT: 10000,
P_PROTECTED_SPLIT: 10000,
P_WIDOW_WORD: 800,
P_WIDOW_LINE: 500,
P_SHORT_LASTLINE: 300,
P_PARTICLE_ISO: 200,
R_PUNCT_BREAK: 80,
R_SHIFT_BREAK: 60,
R_ACCUM_BREAK: 40,
R_SELF_BREAK: 20,
P_OVER_MAXLEN: 30,
P_TOO_SHORT: 10,
/**
* 预留:情绪词落点强化(文档 7.2 / 10.2G 提到)
* - 为避免偏离 10.3 首版权重,本实现默认置 0不影响结果
* - 若后续需要启用,可通过 overrides 提供非 0 权重,并用 configVersion 管理
*/
R_EMOTION_TAIL: 0,
P_EMOTION_BURIED: 0,
} satisfies Weights);
export function mergeWeights(overrides?: Partial<Weights> | null | undefined): Weights {
if (!overrides) return { ...DEFAULT_WEIGHTS };
return { ...DEFAULT_WEIGHTS, ...overrides };
}

View File

@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest';
import type { MeasureWidthImpl } from '../../measure/types';
import { searchBestLayoutApp } from '../index';
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from '../../scoring/index';
function t(text: string, start: number) {
return { text, start, end: start + text.length };
}
describe('textWrap search-engine-app', () => {
it('确定性:同输入多次调用 breaks/lines 必须一致', async () => {
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
const input = {
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
lang: 'EN' as const,
breakpoints: [
{ pos: 1, kind: 'SPACE', priority: 10 },
{ pos: 2, kind: 'SPACE', priority: 10 },
{ pos: 3, kind: 'SPACE', priority: 10 },
],
availableWidth: 6,
maxLines: 2,
lineMode: 'AUTO' as const,
measure: {
contextProfile: 'APP|ios|test',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
},
scoring: {
config: {
weights: DEFAULT_WEIGHTS,
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
ellipsisToken: '…',
tcParticleWhitelist: [],
},
lexicons: DEFAULT_LEXICONS,
debug: true,
},
};
const a = await searchBestLayoutApp(input as any);
const b = await searchBestLayoutApp(input as any);
const c = await searchBestLayoutApp(input as any);
expect(a).toEqual(b);
expect(b).toEqual(c);
expect(impl).toHaveBeenCalled(); // 有测量发生
});
it('lineMode=FIXED无解必须返回 NO_CANDIDATE', async () => {
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
const res = await searchBestLayoutApp({
tokens: [t('I', 0), t('am', 2)],
lang: 'EN',
breakpoints: [{ pos: 1, kind: 'SPACE', priority: 10 }],
availableWidth: 100,
maxLines: 3,
lineMode: 'FIXED',
measure: {
contextProfile: 'APP|ios|fixed',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
},
scoring: {
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
lexicons: DEFAULT_LEXICONS,
},
});
expect(res.ok).toBe(false);
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
});
it('TC H6断点导致下一行行首为标点时必须禁止', async () => {
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
const res = await searchBestLayoutApp({
tokens: [t('我', 0), t('好', 1), t('', 2), t('累', 3)],
lang: 'TC',
// 若选择 pos=2则第二行行首 token 为 ''(应禁止)
breakpoints: [{ pos: 2, kind: 'PUNCT', priority: 30 }],
availableWidth: 100,
maxLines: 2,
lineMode: 'FIXED',
measure: {
contextProfile: 'APP|ios|tc',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
},
scoring: {
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], tcPunctuations: [''] },
lexicons: DEFAULT_LEXICONS,
},
});
// lineMode=FIXED 且唯一断点被禁止,因此无解
expect(res.ok).toBe(false);
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
});
it('TOO_LONG超过阈值直接返回 TOO_LONG', async () => {
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
const tokens = Array.from({ length: 31 }).map((_, i) => t('a', i));
const res = await searchBestLayoutApp({
tokens,
lang: 'EN',
breakpoints: [],
availableWidth: 100,
maxLines: 2,
lineMode: 'AUTO',
measure: {
contextProfile: 'APP|ios|toolong',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
},
scoring: {
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
lexicons: DEFAULT_LEXICONS,
},
});
expect(res.ok).toBe(false);
if (!res.ok) expect(res.reason).toBe('TOO_LONG');
});
});

View File

@@ -0,0 +1,21 @@
import type { Token } from '../core/types';
export const DEFAULT_TOO_LONG_THRESHOLDS = Object.freeze({ EN: 30, TC: 60 });
export function isTooLong(tokens: Token[], lang: 'TC' | 'EN', thresholds: { EN: number; TC: number }): boolean {
const n = Math.max(0, tokens.length | 0);
const limit = lang === 'EN' ? thresholds.EN : thresholds.TC;
return n > limit;
}
export function isLineStartPunctTC(tokens: Token[], pos: number, tcPunctuations: string[]): boolean {
const p = pos | 0;
if (p <= 0) return false; // 第一行不受 H6 影响
const t = tokens[p]?.text ?? '';
return tcPunctuations.includes(t);
}
export function isOverWidth(width: number, availableWidth: number): boolean {
return width > availableWidth;
}

View File

@@ -0,0 +1,221 @@
import type { Token } from '../core/types';
import type { Breakpoint } from '../breakpoints/types';
import { joinTokens } from '../core/joinTokens';
import { measureSliceWidthCached } from '../measure/measureSliceWidthCached';
import { buildTieKey, scoreLayout } from '../scoring/index';
import { DEFAULT_TOO_LONG_THRESHOLDS, isLineStartPunctTC, isOverWidth, isTooLong } from './constraints';
import { insertTopK } from './topK';
import type { BestLayout, PartialLayout, SearchAppConfig, SearchAppInput, SearchAppResult } from './types';
const DEFAULT_CONFIG: SearchAppConfig = {
topK: 10,
tooLongThresholds: DEFAULT_TOO_LONG_THRESHOLDS,
};
function mergeConfig(overrides?: Partial<SearchAppConfig> | null): SearchAppConfig {
if (!overrides) return { ...DEFAULT_CONFIG };
return {
topK: overrides.topK ?? DEFAULT_CONFIG.topK,
tooLongThresholds: overrides.tooLongThresholds ?? DEFAULT_CONFIG.tooLongThresholds,
};
}
function uniqueSortedPositions(bps: Breakpoint[], n: number): number[] {
const set = new Set<number>();
for (const b of bps) {
const p = b.pos | 0;
if (p >= 1 && p <= n - 1) set.add(p);
}
const arr = Array.from(set);
arr.sort((a, b) => a - b);
return arr;
}
function rawSeparatorsForLang(tokensLen: number, lang: 'TC' | 'EN'): string[] | undefined {
if (lang !== 'TC') return undefined;
// TC默认拼接不插入额外空格空格作为 token 自身出现
return Array.from({ length: Math.max(0, tokensLen) }, () => '');
}
function buildLineText(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
return joinTokens(tokens, start, end, rawSeparators);
}
function buildBestLayout(best: PartialLayout, debug?: boolean): BestLayout {
const lines = best.lines.map((l) => l.text);
const wrappedText = lines.join('\n');
const meta = debug
? { breaks: best.breaks, scoreTopTerms: best.scoreTopTerms, score: best.score }
: { breaks: best.breaks };
return { breaks: best.breaks, lines, wrappedText, meta };
}
export async function searchBestLayoutApp(input: SearchAppInput): Promise<SearchAppResult> {
const cfg = mergeConfig(input.config ?? null);
const tokens = input.tokens ?? [];
const lang = input.lang;
const n = tokens.length;
if (isTooLong(tokens, lang, cfg.tooLongThresholds)) {
return { ok: false, reason: 'TOO_LONG', meta: { tokenCount: n } };
}
const maxLines = Math.max(1, input.maxLines | 0);
const availableWidth = input.availableWidth;
const positions = uniqueSortedPositions(input.breakpoints ?? [], n);
const rawSep = rawSeparatorsForLang(tokens.length, lang);
// dp[pos][linesUsed] -> PartialLayout[]
const dp: PartialLayout[][][] = Array.from({ length: n + 1 }, () =>
Array.from({ length: maxLines + 1 }, () => [])
);
dp[0][0] = [
{
pos: 0,
breaks: [],
lines: [],
score: 0,
tieKey: [0, 0, 0, 0, 0], // 空布局占位,后续会用 buildTieKey 覆盖
},
];
const tcPunctuations = input.scoring.config.tcPunctuations ?? ['', '。', '', '', '', '', '、'];
// DP 遍历顺序必须固定
for (let pos = 0; pos <= n; pos++) {
for (let linesUsed = 0; linesUsed <= maxLines - 1; linesUsed++) {
const states = dp[pos][linesUsed];
if (!states || states.length === 0) continue;
// 枚举 nextPosbreakpoints 中 >pos 的位置(升序)+ N
const nextList: number[] = [];
for (const p of positions) if (p > pos) nextList.push(p);
if (n > pos) nextList.push(n);
for (const state of states) {
for (const nextPos of nextList) {
if (nextPos <= pos) continue; // 禁止空行
// H6TC 禁止“行首标点”(断点导致下一行第一个 token 为标点)
// 断点位置为 nextPos因此要检查 tokens[nextPos]
if (lang === 'TC' && nextPos < n && isLineStartPunctTC(tokens, nextPos, tcPunctuations)) {
continue;
}
const lineText = buildLineText(tokens, pos, nextPos, rawSep);
const widthRes = await measureSliceWidthCached({
tokens,
start: pos,
end: nextPos,
context: 'APP',
contextProfile: input.measure.contextProfile,
fontSpec: input.measure.fontSpec,
measureWidthImpl: input.measure.measureWidthImpl,
rawSeparators: rawSep,
});
if (widthRes.width === null) {
// App 搜索必须依赖宽度派;宽度不可用交给 overflow-fallback 统一处理
return {
ok: false,
reason: widthRes.meta.reason === 'MEASURE_FAILED' ? 'MEASURE_FAILED' : 'WIDTH_UNKNOWN',
meta: { tokenCount: n },
};
}
const lineWidth = widthRes.width;
if (isOverWidth(lineWidth, availableWidth)) continue; // H1
const tokenCount = nextPos - pos;
const charCount = lang === 'TC' ? tokenCount : 0;
const newLine = {
start: pos,
end: nextPos,
text: lineText,
width: lineWidth,
tokenCount,
charCount,
};
const newLines = state.lines.concat([newLine]);
const newBreaks = nextPos === n ? state.breaks : state.breaks.concat([nextPos]);
// 构造 layoutCandidate 并评分(为保证确定性,首版直接全量评分)
const layoutCandidate = { breaks: newBreaks, lines: newLines };
const scored = scoreLayout({
tokens,
layoutCandidate: layoutCandidate as any,
lang,
context: 'APP',
availableWidth,
config: input.scoring.config,
lexicons: input.scoring.lexicons,
debug: Boolean(input.scoring.debug),
});
const tieKey = buildTieKey({
scoredLayout: scored as any,
layoutCandidate: layoutCandidate as any,
lang,
context: 'APP',
tokenCount: n,
availableWidth,
idealWidthRatio: input.scoring.config.idealWidthRatio,
}) as number[];
const cand: PartialLayout = {
pos: nextPos,
breaks: newBreaks,
lines: newLines,
score: scored.score,
tieKey,
scoreTopTerms: scored.scoreBreakdown?.terms,
};
dp[nextPos][linesUsed + 1] = insertTopK(dp[nextPos][linesUsed + 1], cand, cfg.topK);
}
}
}
}
// 结束选择
const collect: PartialLayout[] = [];
if (input.lineMode === 'FIXED') {
collect.push(...dp[n][maxLines]);
} else {
for (let linesUsed = 1; linesUsed <= maxLines; linesUsed++) {
collect.push(...dp[n][linesUsed]);
}
}
if (collect.length === 0) {
return { ok: false, reason: 'NO_CANDIDATE', meta: { tokenCount: n } };
}
// TopK 容器内本身已排序,但跨不同 linesUsed 需要再全局选最优
collect.sort((a, b) => {
// 复用 insertTopK 的 compare 规则(在 topK.ts 内部)会更好,但这里直接再排序一次确保稳定
if (a.score !== b.score) return b.score - a.score;
const na = a.tieKey;
const nb = b.tieKey;
const m = Math.min(na.length, nb.length);
for (let i = 0; i < m; i++) {
const av = na[i] ?? 0;
const bv = nb[i] ?? 0;
if (av < bv) return -1;
if (av > bv) return 1;
}
return na.length - nb.length;
});
const best = collect[0]!;
return { ok: true, bestLayout: buildBestLayout(best, Boolean(input.scoring.debug)) };
}

View File

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

View File

@@ -0,0 +1,62 @@
import type { PartialLayout } from './types';
import { compareBreaksLexicographically } from '../core/index';
function compareTieKey(a: number[], b: number[]): number {
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
if (av < bv) return -1;
if (av > bv) return 1;
}
if (a.length < b.length) return -1;
if (a.length > b.length) return 1;
return 0;
}
/**
* TopK 排序(确定性):
* - score 越大越优
* - tieKey 越小越优(按 11 节构造的数值 key
* - breaks 字典序更小者更优11.0A
*/
export function compareLayouts(a: PartialLayout, b: PartialLayout): number {
if (a.score !== b.score) return b.score - a.score;
const t = compareTieKey(a.tieKey, b.tieKey);
if (t !== 0) return t;
return compareBreaksLexicographically(a.breaks, b.breaks);
}
function breaksKey(breaks: number[]): string {
// breaks 序列作为去重 key确定性
return breaks.join(',');
}
/**
* 插入 TopK去重 + 排序 + 截断)。
*
* 去重规则:
* - 同 breaks 仅保留最优score 更高;若相同按 tieKey/breaks 比较)
*/
export function insertTopK(list: PartialLayout[], cand: PartialLayout, k: number): PartialLayout[] {
const K = Math.max(1, k | 0);
const out = list.slice();
const key = breaksKey(cand.breaks);
const idx = out.findIndex((x) => breaksKey(x.breaks) === key);
if (idx >= 0) {
const existing = out[idx]!;
if (compareLayouts(cand, existing) < 0) {
// cand 更差,忽略
return out;
}
out[idx] = cand;
} else {
out.push(cand);
}
out.sort(compareLayouts);
if (out.length > K) out.length = K;
return out;
}

View File

@@ -0,0 +1,71 @@
import type { Token } from '../core/types';
import type { Breakpoint } from '../breakpoints/types';
import type { ContextProfile, FontSpec, MeasureWidthImpl } from '../measure/types';
import type { Lexicons, ScoringConfig, ScoreTerm } from '../scoring/types';
export type LineMode = 'AUTO' | 'FIXED';
export type SearchAppConfig = {
/** TopK文档建议 App=10 */
topK: number;
/** 超长阈值(用于 TOO_LONG */
tooLongThresholds: { EN: number; TC: number };
};
export type SearchMeasureInput = {
contextProfile: ContextProfile;
fontSpec: Partial<FontSpec> | null | undefined;
measureWidthImpl?: MeasureWidthImpl;
};
export type SearchScoringInput = {
config: ScoringConfig;
lexicons: Lexicons;
debug?: boolean;
};
export type SearchAppInput = {
tokens: Token[];
lang: 'TC' | 'EN';
breakpoints: Breakpoint[];
availableWidth: number;
maxLines: number;
lineMode: LineMode;
measure: SearchMeasureInput;
scoring: SearchScoringInput;
config?: Partial<SearchAppConfig>;
};
export type BestLayout = {
breaks: number[];
lines: string[];
wrappedText: string;
meta?: {
breaks: number[];
scoreTopTerms?: ScoreTerm[];
score?: number;
};
};
export type SearchFailureReason = 'TOO_LONG' | 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | 'NO_CANDIDATE';
export type SearchAppResult =
| { ok: true; bestLayout: BestLayout }
| { ok: false; reason: SearchFailureReason; meta?: { tokenCount: number } };
export type PartialLayout = {
pos: number;
breaks: number[];
lines: Array<{
start: number;
end: number;
text: string;
width: number;
tokenCount: number;
charCount: number;
}>;
score: number;
tieKey: number[];
scoreTopTerms?: ScoreTerm[];
};

View File

@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from 'vitest';
import type { MeasureWidthImpl } from '../../measure/types';
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from '../../scoring/index';
import { searchBestLayoutWidget } from '../index';
function t(text: string, start: number) {
return { text, start, end: start + text.length };
}
describe('textWrap search-engine-widget', () => {
it('确定性:同输入多次调用 breaks/lines 必须一致', async () => {
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
const input = {
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
lang: 'EN' as const,
breakpoints: [
{ pos: 1, kind: 'SPACE', priority: 10 },
{ pos: 2, kind: 'SPACE', priority: 10 },
{ pos: 3, kind: 'SPACE', priority: 10 },
],
availableWidth: 6,
maxLines: 2,
context: 'WIDGET' as const,
measure: {
widthMode: 'MEASURE' as const,
contextProfile: 'WIDGET|small|test',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
widgetEnableMeasure: true,
},
scoring: {
config: {
weights: DEFAULT_WEIGHTS,
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
ellipsisToken: '…',
tcParticleWhitelist: [],
},
lexicons: DEFAULT_LEXICONS,
debug: true,
},
config: { beamK: 5, expandM: 12 },
};
const a = await searchBestLayoutWidget(input as any);
const b = await searchBestLayoutWidget(input as any);
const c = await searchBestLayoutWidget(input as any);
expect(a).toEqual(b);
expect(b).toEqual(c);
});
it('widthMode=APPROX仍可输出并标记 meta.reason=WIDTH_UNKNOWN', async () => {
const res = await searchBestLayoutWidget({
tokens: [t('我', 0), t('好', 1), t('累', 2)],
lang: 'TC',
breakpoints: [{ pos: 1, kind: 'BALANCE', priority: 5 }],
// APPROX 模式下availableWidth 与 width 单位都按“token 数”理解
availableWidth: 10,
maxLines: 2,
context: 'WIDGET',
measure: { widthMode: 'APPROX' },
scoring: {
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
lexicons: DEFAULT_LEXICONS,
debug: true,
},
config: { beamK: 3, expandM: 2 },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.bestLayout.meta?.reason).toBe('WIDTH_UNKNOWN');
}
});
it('性能约束expandM/beamK 生效(通过测量调用次数粗略验证)', async () => {
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
const res = await searchBestLayoutWidget({
tokens: [t('a', 0), t('b', 2), t('c', 4), t('d', 6), t('e', 8)],
lang: 'EN',
breakpoints: [
{ pos: 1, kind: 'SPACE', priority: 10 },
{ pos: 2, kind: 'SPACE', priority: 10 },
{ pos: 3, kind: 'SPACE', priority: 10 },
{ pos: 4, kind: 'SPACE', priority: 10 },
],
availableWidth: 100,
maxLines: 3,
context: 'WIDGET',
measure: {
widthMode: 'MEASURE',
contextProfile: 'WIDGET|perf',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
widgetEnableMeasure: true,
},
scoring: {
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
lexicons: DEFAULT_LEXICONS,
},
config: { beamK: 2, expandM: 2 },
});
expect(res.ok).toBe(true);
// 每轮最多 beamK 个 beam每个 beam 最多 expandM 次扩展 -> 粗略上限maxLines*beamK*expandM
expect((impl as any).mock.calls.length).toBeLessThanOrEqual(3 * 2 * 2 + 2);
});
it('TC H6行首标点导致的转移必须被禁止', async () => {
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
const res = await searchBestLayoutWidget({
tokens: [t('我', 0), t('好', 1), t('', 2), t('累', 3)],
lang: 'TC',
breakpoints: [{ pos: 2, kind: 'PUNCT', priority: 30 }],
// 让“一行放下全文”不可能(触发超宽过滤),从而必须依赖断点切分
availableWidth: 3,
maxLines: 2,
context: 'WIDGET',
measure: {
widthMode: 'MEASURE',
contextProfile: 'WIDGET|tc',
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
measureWidthImpl: impl,
widgetEnableMeasure: true,
},
scoring: {
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], tcPunctuations: [''] },
lexicons: DEFAULT_LEXICONS,
},
config: { beamK: 3, expandM: 3 },
});
expect(res.ok).toBe(false);
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
});
});

View File

@@ -0,0 +1,263 @@
import type { Token } from '../core/types';
import type { Breakpoint } from '../breakpoints/types';
import { joinTokens } from '../core/joinTokens';
import { measureSliceWidthCached } from '../measure/measureSliceWidthCached';
import { buildTieKey, scoreLayout } from '../scoring/index';
import { approxWidth, DEFAULT_TOO_LONG_THRESHOLDS, isLineStartPunctTC, isTooLong } from './constraints';
import { insertBeamTopK } from './topK';
import type { BestWidgetLayout, SearchWidgetConfig, SearchWidgetInput, SearchWidgetResult, WidgetBeam } from './types';
const DEFAULT_CONFIG: SearchWidgetConfig = {
beamK: 5,
expandM: 12,
tooLongThresholds: DEFAULT_TOO_LONG_THRESHOLDS,
};
function mergeConfig(overrides?: Partial<SearchWidgetConfig> | null): SearchWidgetConfig {
if (!overrides) return { ...DEFAULT_CONFIG };
return {
beamK: overrides.beamK ?? DEFAULT_CONFIG.beamK,
expandM: overrides.expandM ?? DEFAULT_CONFIG.expandM,
tooLongThresholds: overrides.tooLongThresholds ?? DEFAULT_CONFIG.tooLongThresholds,
};
}
function uniqueSortedPositions(bps: Breakpoint[], n: number): number[] {
const set = new Set<number>();
for (const b of bps) {
const p = b.pos | 0;
if (p >= 1 && p <= n - 1) set.add(p);
}
const arr = Array.from(set);
arr.sort((a, b) => a - b);
return arr;
}
function rawSeparatorsForLang(tokensLen: number, lang: 'TC' | 'EN'): string[] | undefined {
if (lang !== 'TC') return undefined;
// TC默认拼接不插入额外空格空格 token 自己决定展示
return Array.from({ length: Math.max(0, tokensLen) }, () => '');
}
function buildLineText(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
return joinTokens(tokens, start, end, rawSeparators);
}
function mergeApproxReason(a?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED', b?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED') {
if (a === 'MEASURE_FAILED' || b === 'MEASURE_FAILED') return 'MEASURE_FAILED';
if (a === 'WIDTH_UNKNOWN' || b === 'WIDTH_UNKNOWN') return 'WIDTH_UNKNOWN';
return undefined;
}
function buildBestLayout(best: WidgetBeam, debug?: boolean): BestWidgetLayout {
const lines = best.lines.map((l) => l.text);
const wrappedText = lines.join('\n');
const reason = best.lines.reduce<'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | undefined>((acc, l) => {
return mergeApproxReason(acc, l.approxReason);
}, undefined);
const meta = debug
? { breaks: best.breaks, scoreTopTerms: best.scoreTopTerms, score: best.score, reason }
: { breaks: best.breaks, reason };
return { breaks: best.breaks, lines, wrappedText, meta };
}
export async function searchBestLayoutWidget(input: SearchWidgetInput): Promise<SearchWidgetResult> {
const cfg = mergeConfig(input.config ?? null);
const tokens = input.tokens ?? [];
const lang = input.lang;
const n = tokens.length;
if (isTooLong(tokens, lang, cfg.tooLongThresholds)) {
return { ok: false, reason: 'TOO_LONG', meta: { tokenCount: n } };
}
const maxLines = Math.max(1, input.maxLines | 0);
const availableWidth = input.availableWidth;
const positions = uniqueSortedPositions(input.breakpoints ?? [], n);
const rawSep = rawSeparatorsForLang(tokens.length, lang);
const tcPunctuations = input.scoring.config.tcPunctuations ?? ['', '。', '', '', '', '', '、'];
// 初始 beams
let beams: WidgetBeam[] = [
{ pos: 0, breaks: [], lines: [], score: 0, tieKey: [0, 0, 0, 0, 0] },
];
for (let lineIndex = 1; lineIndex <= maxLines; lineIndex++) {
let newBeams: WidgetBeam[] = [];
// 关键保留已完成pos==N的 beams避免“提前完成的解”在后续轮次被丢弃
// 否则会错误地倾向“凑满 maxLines 行”的解,造成断句很怪(例如把“村莊”拆开)
for (const b of beams) {
if (b.pos === n) {
newBeams = insertBeamTopK(newBeams, b, cfg.beamK);
}
}
// beams 顺序固定:按当前排序后的顺序扩展
//insertBeamTopK 会保持排序;这里再 sort 一次防御)
beams = beams.slice().sort((a, b) => {
if (a.score !== b.score) return b.score - a.score;
const na = a.tieKey;
const nb = b.tieKey;
const m = Math.min(na.length, nb.length);
for (let i = 0; i < m; i++) {
const av = na[i] ?? 0;
const bv = nb[i] ?? 0;
if (av < bv) return -1;
if (av > bv) return 1;
}
return na.length - nb.length;
});
for (const beam of beams) {
const pos = beam.pos;
if (pos >= n) continue;
// nextPospos 升序 + N
const nextList: number[] = [];
for (const p of positions) if (p > pos) nextList.push(p);
if (n > pos) nextList.push(n);
// expandM 裁剪:只取前 M 个(确定性:按 pos 升序),但必须保证 N结束边界始终可选
// 否则会出现“明明一行就能结束,却被裁剪掉只能继续拆分”的怪异换行。
const M = Math.max(1, cfg.expandM | 0);
let trimmed: number[];
if (M === 1) {
trimmed = [n];
} else {
const withoutN = nextList.filter((x) => x !== n);
trimmed = withoutN.slice(0, M - 1);
trimmed.push(n);
}
for (const nextPos of trimmed) {
if (nextPos <= pos) continue; // 禁止空行
// H6TC 禁止行首标点(断点导致下一行第一个 token 为标点)
if (lang === 'TC' && nextPos < n && isLineStartPunctTC(tokens, nextPos, tcPunctuations)) {
continue;
}
const tokenCount = nextPos - pos;
const charCount = lang === 'TC' ? tokenCount : 0;
const lineText = buildLineText(tokens, pos, nextPos, rawSep);
let lineWidth: number;
let isApprox = false;
let approxReason: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | undefined;
let widthTrusted = false;
if (input.measure.widthMode === 'APPROX') {
isApprox = true;
approxReason = 'WIDTH_UNKNOWN';
lineWidth = approxWidth(tokenCount, charCount, lang);
} else {
const widgetEnableMeasure = input.measure.widgetEnableMeasure ?? true;
const res = await measureSliceWidthCached({
tokens,
start: pos,
end: nextPos,
context: 'WIDGET',
contextProfile: input.measure.contextProfile,
fontSpec: input.measure.fontSpec,
measureWidthImpl: input.measure.measureWidthImpl,
widgetEnableMeasure,
rawSeparators: rawSep,
});
if (res.width === null) {
isApprox = true;
approxReason = res.meta.reason ?? 'WIDTH_UNKNOWN';
lineWidth = approxWidth(tokenCount, charCount, lang);
} else {
widthTrusted = true;
lineWidth = res.width;
}
}
// 超宽过滤:仅当宽度可信时执行
if (widthTrusted && lineWidth > availableWidth) continue;
const newLine = {
start: pos,
end: nextPos,
text: lineText,
width: lineWidth,
tokenCount,
charCount,
isApprox,
approxReason,
};
const newLines = beam.lines.concat([newLine]);
const newBreaks = nextPos === n ? beam.breaks : beam.breaks.concat([nextPos]);
const layoutCandidate = { breaks: newBreaks, lines: newLines.map(({ isApprox: _a, approxReason: _b, ...rest }) => rest) };
const scored = scoreLayout({
tokens,
layoutCandidate: layoutCandidate as any,
lang,
context: 'WIDGET',
availableWidth,
config: input.scoring.config,
lexicons: input.scoring.lexicons,
debug: Boolean(input.scoring.debug),
});
const tieKey = buildTieKey({
scoredLayout: scored as any,
layoutCandidate: layoutCandidate as any,
lang,
context: 'WIDGET',
tokenCount: n,
availableWidth,
idealWidthRatio: input.scoring.config.idealWidthRatio,
}) as number[];
const cand: WidgetBeam = {
pos: nextPos,
breaks: newBreaks,
lines: newLines,
score: scored.score,
tieKey,
scoreTopTerms: scored.scoreBreakdown?.terms,
};
newBeams = insertBeamTopK(newBeams, cand, cfg.beamK);
}
}
beams = newBeams;
if (beams.length === 0) break;
}
const finished = beams.filter((b) => b.pos === n);
if (finished.length === 0) {
return { ok: false, reason: 'NO_CANDIDATE', meta: { tokenCount: n } };
}
finished.sort((a, b) => {
if (a.score !== b.score) return b.score - a.score;
const na = a.tieKey;
const nb = b.tieKey;
const m = Math.min(na.length, nb.length);
for (let i = 0; i < m; i++) {
const av = na[i] ?? 0;
const bv = nb[i] ?? 0;
if (av < bv) return -1;
if (av > bv) return 1;
}
return na.length - nb.length;
});
const best = finished[0]!;
return { ok: true, bestLayout: buildBestLayout(best, Boolean(input.scoring.debug)) };
}

View File

@@ -0,0 +1,21 @@
import type { Token } from '../core/types';
export const DEFAULT_TOO_LONG_THRESHOLDS = Object.freeze({ EN: 30, TC: 60 });
export function isTooLong(tokens: Token[], lang: 'TC' | 'EN', thresholds: { EN: number; TC: number }): boolean {
const n = Math.max(0, tokens.length | 0);
const limit = lang === 'EN' ? thresholds.EN : thresholds.TC;
return n > limit;
}
export function isLineStartPunctTC(tokens: Token[], nextPos: number, tcPunctuations: string[]): boolean {
const p = nextPos | 0;
if (p <= 0) return false;
const t = tokens[p]?.text ?? '';
return tcPunctuations.includes(t);
}
export function approxWidth(tokenCount: number, charCount: number, lang: 'TC' | 'EN'): number {
return lang === 'EN' ? tokenCount : charCount;
}

View File

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

View File

@@ -0,0 +1,52 @@
import { compareBreaksLexicographically } from '../core/index';
import type { WidgetBeam } from './types';
function compareTieKey(a: number[], b: number[]): number {
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
if (av < bv) return -1;
if (av > bv) return 1;
}
if (a.length < b.length) return -1;
if (a.length > b.length) return 1;
return 0;
}
/**
* BeamK 排序(确定性):
* - score 越大越优
* - tieKey 越小越优
* - breaks 字典序更小者更优11.0A
*/
export function compareBeams(a: WidgetBeam, b: WidgetBeam): number {
if (a.score !== b.score) return b.score - a.score;
const t = compareTieKey(a.tieKey, b.tieKey);
if (t !== 0) return t;
return compareBreaksLexicographically(a.breaks, b.breaks);
}
function breaksKey(breaks: number[]): string {
return breaks.join(',');
}
export function insertBeamTopK(list: WidgetBeam[], cand: WidgetBeam, k: number): WidgetBeam[] {
const K = Math.max(1, k | 0);
const out = list.slice();
const key = breaksKey(cand.breaks);
const idx = out.findIndex((x) => breaksKey(x.breaks) === key);
if (idx >= 0) {
const existing = out[idx]!;
if (compareBeams(cand, existing) < 0) return out;
out[idx] = cand;
} else {
out.push(cand);
}
out.sort(compareBeams);
if (out.length > K) out.length = K;
return out;
}

View File

@@ -0,0 +1,91 @@
import type { Token } from '../core/types';
import type { Breakpoint } from '../breakpoints/types';
import type { ContextProfile, FontSpec, MeasureWidthImpl } from '../measure/types';
import type { Lexicons, ScoringConfig, ScoreTerm } from '../scoring/types';
export type WidthMode = 'MEASURE' | 'APPROX';
export type SearchWidgetConfig = {
beamK: number;
expandM: number;
tooLongThresholds: { EN: number; TC: number };
};
export type SearchWidgetMeasureInput =
| { widthMode: 'APPROX' }
| {
widthMode: 'MEASURE';
contextProfile: ContextProfile;
fontSpec: Partial<FontSpec> | null | undefined;
measureWidthImpl?: MeasureWidthImpl;
/** Widget 是否启用测量;默认 true本模块仅在明确 MEASURE 时开启) */
widgetEnableMeasure?: boolean;
};
export type SearchWidgetScoringInput = {
config: ScoringConfig;
lexicons: Lexicons;
debug?: boolean;
};
export type SearchWidgetInput = {
tokens: Token[];
lang: 'TC' | 'EN';
breakpoints: Breakpoint[];
/**
* 可用宽度(与 line.width 的单位必须一致):
* - MEASURE像素等真实测量单位
* - APPROX建议用“近似单位”例如每行可容纳的 token 数)
*/
availableWidth: number;
maxLines: number;
context: 'WIDGET';
measure: SearchWidgetMeasureInput;
scoring: SearchWidgetScoringInput;
config?: Partial<SearchWidgetConfig>;
};
export type WidgetLayoutMeta = {
breaks: number[];
scoreTopTerms?: ScoreTerm[];
score?: number;
/**
* 仅当进入 approx/降级时填入,便于 integration/打点模块统一处理:
* - WIDTH_UNKNOWN未启用测量或测量能力缺失
* - MEASURE_FAILED测量抛错或返回非法
*/
reason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
};
export type BestWidgetLayout = {
breaks: number[];
lines: string[];
wrappedText: string;
meta?: WidgetLayoutMeta;
};
export type SearchWidgetFailureReason = 'TOO_LONG' | 'NO_CANDIDATE';
export type SearchWidgetResult =
| { ok: true; bestLayout: BestWidgetLayout }
| { ok: false; reason: SearchWidgetFailureReason; meta?: { tokenCount: number } };
export type WidgetBeam = {
pos: number;
breaks: number[];
lines: Array<{
start: number;
end: number;
text: string;
width: number;
tokenCount: number;
charCount: number;
/** 该行是否为近似宽度(用于 meta.reason */
isApprox: boolean;
approxReason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
}>;
score: number;
tieKey: number[];
scoreTopTerms?: ScoreTerm[];
};

View File

@@ -0,0 +1,55 @@
import type { MeasureWidthImpl } from './measure/types';
import type { ScoreTerm } from './scoring/types';
export type OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT';
export type LineMode = 'AUTO' | 'FIXED';
export type TextWrapContext = 'APP' | 'WIDGET';
export type Lang = 'TC' | 'EN';
export type WrapTextConstraints = {
protectedPhrases?: string[];
forbiddenBreakRanges?: Array<{ start: number; end: number }>;
};
export type FontSpecInput = {
fontSize: number;
fontFamily: string;
fontWeight: string;
};
export type WrapTextInput = {
text: string;
lang: Lang;
availableWidth: number;
maxLines: number;
context: TextWrapContext;
fontSpec?: Partial<FontSpecInput> | null;
/** 可选注入测量实现APP 场景强烈建议提供WIDGET 默认不启用) */
measureWidthImpl?: MeasureWidthImpl;
/**
* 测量缓存隔离 key可选
* 口径建议:`APP|ios|<scale?>` / `WIDGET|small`。
*/
contextProfile?: string;
overflowMode?: OverflowMode;
lineMode?: LineMode;
constraints?: WrapTextConstraints;
configVersion?: string;
debug?: boolean;
};
export type WrapTextMeta = {
configVersion?: string;
fallback_type?: 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT';
overflow_type?: 'NONE' | 'ELLIPSIS' | 'CLIP';
reason?: 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'WIDOW' | 'PARTICLE' | 'TOO_LONG' | string;
breaks?: number[];
scoreTopTerms?: ScoreTerm[];
};
export type WrapTextOutput = {
lines: string[];
wrappedText: string;
meta?: WrapTextMeta;
};

View File

@@ -0,0 +1,173 @@
import type { Token } from './core/types';
import { normalizeWhitespace, tokenizeEN } from './core/index';
import { segmentGraphemes } from './grapheme/index';
import { generateBreakpoints } from './breakpoints/index';
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from './scoring/index';
import { searchBestLayoutApp } from './searchApp/index';
import { searchBestLayoutWidget } from './searchWidget/index';
import { applyOverflowFallback } from './overflow/index';
import type { WrapTextInput, WrapTextMeta, WrapTextOutput } from './types';
function tokenizeTC(normalizedText: string): Token[] {
const { clusters } = segmentGraphemes(normalizedText, 'PREFERRED');
const tokens: Token[] = [];
let idx = 0;
for (const c of clusters) {
const start = idx;
idx += c.length;
tokens.push({ text: c, start, end: idx });
}
return tokens;
}
export async function wrapText(input: WrapTextInput): Promise<WrapTextOutput> {
const lang = input.lang;
const context = input.context;
const configVersion = input.configVersion ?? 'v1';
const overflowMode: WrapTextInput['overflowMode'] =
input.overflowMode ?? (context === 'WIDGET' ? 'ELLIPSIS' : 'CLIP');
const lineMode = input.lineMode ?? 'AUTO';
const debug = Boolean(input.debug);
const { normalizedText } = normalizeWhitespace(input.text, 'NORMALIZE');
const tokens = lang === 'EN' ? tokenizeEN(normalizedText) : tokenizeTC(normalizedText);
// 断点候选
const { breakpoints } = generateBreakpoints({
tokens: tokens as any,
lang,
maxLines: input.maxLines,
constraints: input.constraints,
config: { tcMaxCandidateBreaks: 80, tcPunctuations: ['', '。', '', '', '', '', '、'], balanceRange: 3 },
});
// scoringprotectedPhrases 从 constraints 注入
const lexicons = { ...DEFAULT_LEXICONS, protectedPhrases: input.constraints?.protectedPhrases ?? DEFAULT_LEXICONS.protectedPhrases };
const tcPunctuations: string[] = ['', '。', '', '', '', '', '、'];
const scoringConfig = {
weights: DEFAULT_WEIGHTS,
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
ellipsisToken: '…',
tcParticleWhitelist: [],
tcPunctuations,
};
// 搜索
if (context === 'APP') {
const res = await searchBestLayoutApp({
tokens,
lang,
breakpoints: breakpoints as any,
availableWidth: input.availableWidth,
maxLines: input.maxLines,
lineMode,
measure: {
contextProfile: input.contextProfile ?? 'APP',
fontSpec: input.fontSpec ?? null,
measureWidthImpl: input.measureWidthImpl,
},
scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug },
});
if (res.ok) {
const meta: WrapTextMeta = { configVersion, breaks: res.bestLayout.meta?.breaks, scoreTopTerms: res.bestLayout.meta?.scoreTopTerms };
return { lines: res.bestLayout.lines, wrappedText: res.bestLayout.wrappedText, meta };
}
// APP若测量失败/不可用,且调用方未要求 SYSTEM_DEFAULT则尝试降级为“近似宽度 Beam”
// 目的:保证尽量产出带 \n 的 wrappedText而不是完全依赖系统自动换行
if (overflowMode !== 'SYSTEM_DEFAULT' && (res.reason === 'WIDTH_UNKNOWN' || res.reason === 'MEASURE_FAILED')) {
const fontSize = Number.isFinite((input.fontSpec as any)?.fontSize) ? Number((input.fontSpec as any)?.fontSize) : 22;
// 把像素宽度粗略换算为“token 容量”,只用于降级路径(确定性)
const approxCapacity =
lang === 'EN'
? Math.max(1, Math.floor(input.availableWidth / Math.max(1, Math.round(fontSize * 0.55))))
: Math.max(1, Math.floor(input.availableWidth / Math.max(1, Math.round(fontSize * 0.95))));
const approxRes = await searchBestLayoutWidget({
tokens,
lang,
breakpoints: breakpoints as any,
availableWidth: approxCapacity,
maxLines: input.maxLines,
context: 'WIDGET',
measure: { widthMode: 'APPROX' },
scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug },
config: { beamK: 5, expandM: 12 },
});
if (approxRes.ok) {
const meta: WrapTextMeta = {
configVersion,
breaks: approxRes.bestLayout.meta?.breaks,
scoreTopTerms: approxRes.bestLayout.meta?.scoreTopTerms,
reason: res.reason,
};
return { lines: approxRes.bestLayout.lines, wrappedText: approxRes.bestLayout.wrappedText, meta };
}
}
// overflow/fallback最终兜底
const fb = await applyOverflowFallback({
tokens,
lang,
context: 'APP',
availableWidth: input.availableWidth,
maxLines: input.maxLines,
overflowMode: overflowMode as any,
ellipsisToken: scoringConfig.ellipsisToken,
reason: res.reason,
partialLayout: null,
tcPunctuations: scoringConfig.tcPunctuations,
});
return {
lines: fb.lines,
wrappedText: fb.wrappedText,
meta: { configVersion, fallback_type: fb.meta.fallback_type, overflow_type: fb.meta.overflow_type, reason: fb.meta.reason },
};
}
// WIDGET默认 APPROX单位由 availableWidth 决定),可在上层选择 MEASURE 并注入测量能力
const resW = await searchBestLayoutWidget({
tokens,
lang,
breakpoints: breakpoints as any,
availableWidth: input.availableWidth,
maxLines: input.maxLines,
context: 'WIDGET',
measure: { widthMode: 'APPROX' },
scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug },
});
if (resW.ok) {
const meta: WrapTextMeta = {
configVersion,
breaks: resW.bestLayout.meta?.breaks,
scoreTopTerms: resW.bestLayout.meta?.scoreTopTerms,
reason: resW.bestLayout.meta?.reason,
};
return { lines: resW.bestLayout.lines, wrappedText: resW.bestLayout.wrappedText, meta };
}
const fb = await applyOverflowFallback({
tokens,
lang,
context: 'WIDGET',
availableWidth: input.availableWidth,
maxLines: input.maxLines,
overflowMode: overflowMode as any,
ellipsisToken: scoringConfig.ellipsisToken,
reason: resW.reason,
partialLayout: null,
tcPunctuations: scoringConfig.tcPunctuations,
});
return {
lines: fb.lines,
wrappedText: fb.wrappedText,
meta: { configVersion, fallback_type: fb.meta.fallback_type, overflow_type: fb.meta.overflow_type, reason: fb.meta.reason },
};
}

View File

@@ -4,6 +4,7 @@ from datetime import datetime, timezone
from typing import Any, Literal, Optional from typing import Any, Literal, Optional
import httpx import httpx
import redis
from fastapi import APIRouter, Depends, Header, HTTPException, Query from fastapi import APIRouter, Depends, Header, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
@@ -13,8 +14,10 @@ from app.api.limits import rate_limit_push_by_ip
from app.core.config import get_settings from app.core.config import get_settings
from app.db.models.push_preference import PushPreference from app.db.models.push_preference import PushPreference
from app.db.models.push_token import PushToken from app.db.models.push_token import PushToken
from app.db.models.push_send_log import PushSendLog
from app.db.session import get_db from app.db.session import get_db
from app.features.user_profile_scoring.types import UserProfileV1_2 from app.features.user_profile_scoring.types import UserProfileV1_2
from app.worker import celery_app
router = APIRouter( router = APIRouter(
@@ -260,3 +263,84 @@ async def test_push(
_ = accept_language _ = accept_language
return {"status": "ok", "expo": expo_res} return {"status": "ok", "expo": expo_res}
def _env_prefix(app_env: str) -> str:
"""
根据环境生成前缀:
- dev -> dev
- prod -> pro
"""
return "dev" if str(app_env) == "dev" else "pro"
@router.get("/scheduler/health")
async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any]:
"""
推送“定时服务”健康检查(用于容器内验证)。
返回内容(尽量不暴露敏感信息):
- Redis是否可连通
- Worker是否至少有一个 worker 在线inspect ping
- Beat是否在跑beat 心跳 key 是否在持续刷新)
- DB是否可查询到 push_send_log 的最新时间(辅助定位排程是否生成)
"""
settings = get_settings()
prefix = _env_prefix(settings.app_env)
beat_key = f"{prefix}:beat:heartbeat"
out: dict[str, Any] = {
"env": settings.app_env,
"redis": {"ok": False},
"worker": {"ok": False, "worker_count": 0},
"beat": {"ok": False, "last_heartbeat_at": None, "age_seconds": None},
"db": {"ok": False, "push_send_log_latest_created_at": None},
"now_utc": datetime.now(timezone.utc).isoformat(),
}
# 1) Redis 连通性 + 读取 beat 心跳
try:
r = redis.Redis.from_url(settings.celery_broker_url, decode_responses=True)
r.ping()
out["redis"]["ok"] = True
hb = r.get(beat_key)
if hb:
out["beat"]["last_heartbeat_at"] = hb
try:
# Python 3.11+ 支持解析 ISO8601含 +00:00
hb_dt = datetime.fromisoformat(hb.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
age = int((now - hb_dt.astimezone(timezone.utc)).total_seconds())
out["beat"]["age_seconds"] = age
# 2 分钟内认为健康beat 每分钟刷新一次)
out["beat"]["ok"] = age <= 120
except Exception:
# 解析失败:至少说明 key 存在,但时间格式异常
out["beat"]["ok"] = False
except Exception as e:
out["redis"]["error"] = f"{type(e).__name__}: {e}"
# 2) Worker 在线性inspect ping
try:
insp = celery_app.control.inspect(timeout=1.0)
pings = insp.ping() or {}
if isinstance(pings, dict):
out["worker"]["worker_count"] = len(pings)
out["worker"]["ok"] = len(pings) > 0
except Exception as e:
out["worker"]["error"] = f"{type(e).__name__}: {e}"
# 3) DB查询 push_send_log 最新创建时间(用于判断排程是否有生成)
try:
q = select(PushSendLog.created_at).order_by(PushSendLog.created_at.desc()).limit(1)
row = await db.execute(q)
latest = row.scalar_one_or_none()
out["db"]["ok"] = True
out["db"]["push_send_log_latest_created_at"] = latest.isoformat() if latest else None
except Exception as e:
out["db"]["error"] = f"{type(e).__name__}: {e}"
return out

View File

@@ -10,4 +10,5 @@ Celery 任务集合。
from app.tasks import ping as _ping # noqa: F401 from app.tasks import ping as _ping # noqa: F401
from app.tasks import reco as _reco # noqa: F401 from app.tasks import reco as _reco # noqa: F401
from app.tasks import push as _push # noqa: F401 from app.tasks import push as _push # noqa: F401
from app.tasks import ops as _ops # noqa: F401

41
server/app/tasks/ops.py Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime, timezone
import redis
from celery import shared_task
from app.core.config import get_settings
def _env_prefix(app_env: str) -> str:
"""
根据环境生成前缀:
- dev -> dev
- prod -> pro
"""
return "dev" if str(app_env) == "dev" else "pro"
@shared_task(name="tasks.ops.beat_heartbeat")
def beat_heartbeat() -> dict[str, str]:
"""
Beat 心跳任务(用于健康检查)。
作用:
- 由 Celery Beat 每分钟触发一次
- 写入 Redis 心跳 key并设置 TTL
- API 侧读取该 key可判断 beat 是否在运行
"""
settings = get_settings()
prefix = _env_prefix(settings.app_env)
key = f"{prefix}:beat:heartbeat"
now = datetime.now(timezone.utc).isoformat()
r = redis.Redis.from_url(settings.celery_broker_url, decode_responses=True)
# TTL 设短一些:一旦 beat 挂了,很快就能从“过期/缺失”判断出来
r.set(key, now, ex=180)
return {"status": "ok", "key": key, "at": now}

View File

@@ -45,6 +45,12 @@ celery_app.conf.update(
# - 这里按 UTC 00:10 触发一次;具体时间可按运维习惯调整 # - 这里按 UTC 00:10 触发一次;具体时间可按运维习惯调整
celery_app.conf.timezone = "UTC" celery_app.conf.timezone = "UTC"
celery_app.conf.beat_schedule = { celery_app.conf.beat_schedule = {
# Beat 心跳:用于 API 健康检查判断 beat 是否在跑
"ops-beat-heartbeat": {
"task": "tasks.ops.beat_heartbeat",
"schedule": crontab(minute="*/1"),
"options": {"queue": f"{prefix}:celery"},
},
"push-generate-daily-schedule": { "push-generate-daily-schedule": {
"task": "tasks.push.generate_daily_schedule", "task": "tasks.push.generate_daily_schedule",
"schedule": crontab(minute=10, hour=0), "schedule": crontab(minute=10, hour=0),

BIN
server/celerybeat-schedule Normal file

Binary file not shown.

View File

@@ -0,0 +1,162 @@
# breakpoint-candidates技术计划
## 1. 计划目标
基于 `spec.md``设计说明文档/文档换行算法.md v1.2.1`,实现“候选断点生成与裁剪”模块,输出**可控规模、确定性排序**的 breakpoints 集合,保证:
- EN/TC 断点生成口径一致(断点 `pos` 均是 token 边界索引)
- 同输入必定同输出(去重、排序、裁剪与过滤全流程确定性)
- 候选规模上限严格生效(尤其 TC
- 支持约束过滤:`forbiddenBreakRanges`(必须)与 `protectedPhrases`(可选优化)
本模块只产出 breakpoints不做组合搜索与评分。
## 2. 默认技术决策(本计划采用)
- **输出结构**`Array<{ pos, kind, priority }>`,最终按 `pos` 升序
- **去重策略**:同一 `pos` 若出现多个来源候选,保留 `priority` 更高者priority 相同按 `kind` 固定序优先)
- **裁剪策略TC**:先按“候选重要性排序”截断到 `tcMaxCandidateBreaks`,再按 `pos` 升序输出
- **过滤策略**
- 必做:`forbiddenBreakRanges` 命中直接剔除
- 可选优化:若已计算 `protectedPhrases` 的 span可在生成阶段剔除 span 内断点(否则交给评分阶段强惩罚淘汰)
## 3. 输入/输出与关键口径
### 3.1 输入(来自上游)
- `tokens: Token[]`
- ENWORD tokens不包含 SPACE token
- TCgrapheme 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、确定性

View File

@@ -0,0 +1,51 @@
# breakpoint-candidates子模块规范
## 子模块名称
breakpoint-candidates候选断点生成与裁剪
## 目标描述
基于 token 序列生成“可控规模、确定性排序”的候选断点集合breakpoints并应用去重、排序与约束过滤确保后续搜索器复杂度可控且跨端一致。
本模块输出的是“断点候选集合”,不负责“组合搜索选最优”。
## 输入/输出定义
### 输入
- `tokens: Token[]`
- `lang: 'TC' | 'EN'`
- `maxLines: number`
- `constraints?: { protectedPhrases?: string[]; forbiddenBreakRanges?: Array<{ start: number; end: number }> }`
- `config: { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }`
### 输出
- `breakpoints: Array<{ pos: number; kind: 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER'; priority: number }>`
- **pos**token 边界索引0..N
- **排序**:按 `pos` 升序(最终输出必须确定性)
- `meta?: { pruned: boolean; originalCount: number; finalCount: number }`
## 验收标准(可验证)
- **EN 口径**
- tokens 仅为 WORD不生成 SPACE token断点仅存在于词间
- 每个词边界产生 `kind=SPACE` 的候选断点(按配置可做裁剪,但必须确定性)
- **TC 口径**
- 标点后断点 `kind=PUNCT` 优先级最高
- 空格后断点 `kind=SPACE` 次之
- BALANCE 断点:围绕理想切分点附近生成少量断点(允许落在短语 span 内,是否可用交给评分惩罚)
- **去重/排序/过滤确定性**
- 同一 `pos` 多来源断点:保留 priority 更高者
- 输出按 `pos` 升序
- `forbiddenBreakRanges` 命中者必定被剔除
- **规模上限生效**
- TC 输出候选断点数不超过 `tcMaxCandidateBreaks`
- 截断策略确定性(按 priority + 距离理想位置等固定规则)
## 依赖与关联
- **依赖**`core-contract`token 与索引语义)、`grapheme-segmentation`TC tokens
- **被依赖**`search-engine-app``search-engine-widget`

View File

@@ -0,0 +1,164 @@
# breakpoint-candidates任务清单
> 对应计划:`spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md`
>
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
---
## 0. 任务标记规则
- 用勾选框标记执行状态:
- `[ ]` 未完成
- `[x]` 已完成
- 每个任务必须可独立验收(有明确产出与检查方式)。
- 所有代码注释必须为简体中文,并把“去重/裁剪/排序/过滤”的**确定性口径**写死,避免后续模块漂移。
---
## 1. 前置对齐(口径必须写死)
- [x] 1.1 明确 `pos` 的边界范围:仅输出 `pos ∈ [1, N-1]`
- **原因**`pos=0/N` 属于搜索器的起止边界,不应作为候选断点
- **验收**:单测覆盖 `N=0/1/2` 等边界输入,不会输出非法 pos。
- [x] 1.2 明确 `forbiddenBreakRanges` 的区间口径(写死一种)
- **本任务采用**:闭区间 `start <= pos && pos <= end`
- **验收**单测能验证闭区间边界命中start/end 两端都被剔除)。
---
## 2. 目录与代码骨架(客户端侧实现)
- [x] 2.1 新建目录 `client/src/features/textWrap/breakpoints/`
- **产出**(建议文件):
- `types.ts`Breakpoint/Config/Constraints
- `generateBreakpoints.ts`(主入口,纯函数)
- `tcCandidates.ts`TCPUNCT/SPACE/BALANCE 生成)
- `enCandidates.ts`ENSPACE 断点生成)
- `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=SPACEpriority 固定)
- **规则**
-`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 TCBALANCE 断点生成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 覆盖 TCPUNCT/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 能反映该子模块已完成,便于全局追踪。

View File

@@ -0,0 +1,111 @@
# core-contract技术计划
## 1. 计划目标
基于 `spec.md``设计说明文档/文档换行算法.md v1.2.1`,落地跨端一致的“基础口径与契约”,为后续断点生成、搜索与评分提供稳定输入与确定性工具,确保:
- EN/TC 的 **token 索引体系** 与断点 `pos` 语义固定
- 文本 **可重组**:任意 `[start..end)` 区间可稳定还原为行文本
- EN 关键词命中规则严格为 **全词等值匹配**(避免 substring 误伤)
- 空白归一化策略可配置但默认一致(推荐 NORMALIZE
- 提供可复用的 **确定性比较工具**(用于 breaks 字典序/tieKey 比较)
## 2. 默认技术决策(本计划采用)
- **空白策略**:默认 `whitespacePolicy=NORMALIZE`
- 行为:折叠连续空白为 1 个空格、去首尾空白
- 并在 meta后续模块中打点 `hadMultiWhitespace`(本模块先预留布尔返回位)
- **EN tokenize**:仅生成 WORD token不生成 SPACE token以空白分隔标点按“极简派”保留在词内
- **TC tokenize**:本模块只定义接口,具体分割由 `grapheme-segmentation` 提供
- **EN 关键词命中**`lowercase → strip 两端常见标点 → 等值比较`,禁止 contains/substring
- **确定性比较**breaks 字典序比较按“逐项比较 + 公共前缀相同则更短者更小”
## 3. 目录与产物
本子模块目录:
- `spec_kit/Text Wrap/modules/core-contract/spec.md`
- `spec_kit/Text Wrap/modules/core-contract/plan.md`(本文)
建议未来代码落位(实现阶段再定,不在本计划强制):
- `client/src/features/textWrap/core/`(或 `client/src/utils/textWrap/`
## 4. 设计与实现要点(按落地顺序)
### 4.1 文本预处理normalizeWhitespace
实现 `normalizeWhitespace(text) -> { normalizedText, hadMultiWhitespace }`
- 规则:
- 把任意连续空白(空格/制表/换行等)折叠为单个空格
- 去除首尾空白
- 注意:
- 该规范会改变输入文本;必须作为“算法契约”的一部分固定下来
- 若后续产品需要保留原始空白,则走 `PRESERVE` 分支并输出 `rawSeparators`(见 4.3
### 4.2 EN tokenizeword 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 以单元测试或脚本方式验证(实现阶段落地)

View File

@@ -0,0 +1,59 @@
# core-contract子模块规范
## 子模块名称
core-contract核心口径与契约
## 目标描述
定义并固化跨端一致的“基础口径”,为后续断点生成、搜索与评分提供统一契约,避免实现偏差:
- **索引体系**EN/TC 的 token 与断点 `pos` 语义
- **文本重组**:从 token 区间稳定重组回行文本
- **规范化**:空白归一化策略(默认折叠空白、去首尾)
- **EN 关键词命中规则**:全词等值匹配(`lowercase → strip 两端常见标点 → 等值比较`),禁止 substring/contains
- **配置与版本**`configVersion` 的语义与回溯字段;全端一致的默认值入口
- **确定性比较**layout tie-break 的字典序比较口径(作为后续模块复用工具)
本模块不负责“换行搜索”,只负责**定义数据结构与基础函数**。
## 输入/输出定义
### 输入
- `text: string`
- `lang: 'TC' | 'EN'`
- `options?: { preserveRawSeparators?: boolean }`
- `config: { punctuationStripSetEN: string[]; whitespacePolicy: 'NORMALIZE' | 'PRESERVE' }`
### 输出
- `normalizedText: string`
- `tokens: Array<{ text: string; start: number; end: number }>`
- ENtoken 仅为 WORD不产生 SPACE token标点视为词内字符
- TCtoken 为 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`

View File

@@ -0,0 +1,150 @@
# core-contract任务清单
> 对应计划:`spec_kit/Text Wrap/modules/core-contract/plan.md`
>
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
---
## 0. 任务标记规则
- 用勾选框标记执行状态:
- `[ ]` 未完成
- `[x]` 已完成
- 每个任务必须可独立验收(有明确产出与检查方式)。
- 涉及“口径”的任务,必须在代码注释中写清楚(简体中文),避免后续模块实现漂移。
---
## 1. 文档对齐(先把口径写死,避免实现漂移)
- [x] 1.1 复核 `core-contract/spec.md``core-contract/plan.md` 的一致性
- **检查点**
- `whitespacePolicy`NORMALIZE/PRESERVE语义一致
- EN token 规则为“极简派”(不拆标点、不生成 SPACE token
- EN 关键词命中为“全词等值匹配”(禁止 substring
- breaks 字典序比较规则清晰且无歧义
- **验收**:两份文档无冲突描述;关键字段命名一致。
- [x] 1.2 明确 `start/end` 的索引口径(以 normalizedText 为基准)并写入代码注释与 README如有
- **原因**:跨端 span/调试定位会依赖该口径
- **验收**:任意 token 的 `start/end` 都可映射到同一份文本基准normalizedText
---
## 2. 目录与代码骨架(客户端侧优先落地)
> 说明:当前仓库已有 `client/src/features/*` 结构Text Wrap 建议也放到 `features/` 下,便于后续 Home/Widget 复用。
- [x] 2.1 新建目录 `client/src/features/textWrap/core/`
- **产出**(建议文件):
- `types.ts`Token/Config/Options 类型
- `normalizeWhitespace.ts`
- `tokenizeEN.ts`
- `joinTokens.ts`
- `enKeyword.ts`normalizeENKeyword/matchENKeyword
- `compare.ts`breaks 字典序比较)
- `index.ts`(统一导出)
- **验收**:目录存在且可被 TS 正常 import不报路径错误
- [x] 2.2 定义 `Token` 与基础配置类型(只含 core-contract 需要的字段)
- **要求**
- `Token` 至少包含 `text/start/end`
- `CoreConfig` 至少包含 `whitespacePolicy``punctuationStripSetEN`
- **验收**:类型定义满足后续函数签名需要,且命名清晰。
---
## 3. 核心函数实现(纯函数 + 确定性)
- [x] 3.1 实现 `normalizeWhitespace(text)`(默认 NORMALIZE
- **规则**
- 连续空白折叠为单个空格
- 去除首尾空白
- 返回 `hadMultiWhitespace`(用于后续 meta 打点)
- **验收**
- 输入 `" a b \n c "` 输出 `"a b c"`
- `hadMultiWhitespace` 在出现折叠/trim 时为 true
- [x] 3.2 实现 `tokenizeEN(normalizedText)`(极简派)
- **规则**
- 以空格切分为 WORD tokens
- 标点作为 token.text 的一部分(不拆)
- 不生成 SPACE token
- **验收**
- `"I am so tired"``[I, am, so, tired]`
- `"tired."` 为单 token
- [x] 3.3 实现 `joinTokens(tokens, start, end, separators?)`
- **规则**
- 默认用单空格 join `[start..end)` 的 token.text
- `start/end` 为半开区间,越界/空区间需有明确行为(建议:空区间返回空字符串,交由上层硬约束处理)
- **验收**
- tokens=`[I, am, so, tired]``join(0,2)``"I am"`
- [x] 3.4 实现 EN 关键词命中:`normalizeENKeyword` + `matchENKeyword`
- **规则**
- `lowercase`
- strip 两端常见标点(使用配置 `punctuationStripSetEN`,全端一致)
- 等值比较(禁止 substring
- **验收**
- keyword=`but``but,` 命中;`rebuttal` 不命中
- [x] 3.5 实现 breaks 字典序比较 `compareBreaksLexicographically(a, b)`
- **规则**
- 从 index=0 起逐项比较,首个不同元素更小者更小
- 公共前缀相同则更短数组更小
- **验收**
- `[2] < [3]`
- `[2] < [2, 5]`
- `[2, 3] > [2]`
---
## 4. 单元测试Vitest纯函数为主
- [x] 4.1 新建测试目录 `client/src/features/textWrap/core/__tests__/`
- **验收**:测试文件可被现有 test runner 发现。
- [x] 4.2 为 `normalizeWhitespace` 增加用例
- **覆盖**:多空格、换行、首尾空白、空字符串、全空白字符串
- **验收**:测试断言输出字符串与 `hadMultiWhitespace` 符合预期。
- [x] 4.3 为 `tokenizeEN` + `joinTokens` 增加用例
- **覆盖**:普通句子、带标点的 token、单词间多个空格先 normalize 再 tokenize
- **验收**tokens 序列与 join 后文本完全一致且确定。
- [x] 4.4 为 `matchENKeyword` 增加用例
- **覆盖**大小写、两端标点、误伤样例rebuttal vs but
- **验收**:命中与不命中行为符合 spec。
- [x] 4.5 为 breaks 字典序比较增加用例
- **验收**:比较规则在多组数组上输出稳定顺序。
---
## 5. 最终自检清单(合入前)
- [x] 5.1 `tsc --noEmit` 通过(或项目既有 TS 检查命令通过)
- **验收**:无类型错误。
- [x] 5.2 `vitest` 通过(或项目既有测试命令通过)
- **验收**:新增用例全部通过,不影响现有测试。
- [x] 5.3 代码注释口径检查(简体中文)
- **检查点**
- EN 极简派与“断点只在词间”
- 全词等值匹配(禁止 substring
- `start/end` 基于 normalizedText 的口径说明
- **验收**:后续模块开发者只看代码也不会产生歧义。
---
## 6. 文档回写(任务清单执行完毕后必须做)
- [x] 6.1 在 `spec_kit/overview.md``Text Wrap` 条目下补充执行状态
- **建议写法**
- 增加一行:`- **已完成编码(阶段性)**core-contract核心口径与契约`
- **验收**overview 能反映该子模块已完成,便于全局追踪。

View File

@@ -0,0 +1,57 @@
# golden-tests技术计划
## 1. 计划目标
建立可持续回归体系,覆盖:
- **Golden Cases**:固定输入 → 固定输出lines/wrappedText/meta作为“可治理基线”
- **性质测试property tests**
- 确定性:同输入多次调用输出一致
- 近似单调性availableWidth 变小不会让任一行变得更宽(同测量口径下)
- maxLines 不变差maxLines 增加时至少不从可解变 overflow
本模块产出测试数据与测试规则,不产出业务功能。
## 2. 约束与策略
### 2.1 测量可控
- APP使用稳定的测量 mock例如 `width = text.length`)保证 CI 可运行
- WIDGET使用固定 profile 或 `widthMode=APPROX`(单位为 tokenCount/graphemeCount
### 2.2 Golden 的组织方式
- 采用 TS fixture便于类型校验与可读性
- Golden case 至少包含:
- `text/lang/context/availableWidth/maxLines/overflowMode/lineMode/configVersion`
- 期望:`expected.lines/expected.wrappedText`(可选 meta 断言)
### 2.3 覆盖面(首版)
首版优先覆盖“易回归且高价值”的样例集:
- EN/TC 各若干条:短/长、含标点/无标点、含 emoji、含 shift/accum/self、极窄宽度
> 说明:文档建议每种语言 20 条;首版先落最小可运行集合,后续迭代扩充但保持可解释与版本化。
## 3. 测试实现结构(客户端)
- `client/src/features/textWrap/golden/fixtures.ts`Golden cases
- `client/src/features/textWrap/golden/__tests__/golden.test.ts`
- Golden 断言lines/wrappedText
- 性质测试determinism/monotonic/maxLines
测试内暂时使用“测试版 wrapTextHarness”把已实现模块串起来
- normalize/tokenizeEN=tokenizeENTC=segmentGraphemes
- generateBreakpoints
- search-engine-app / search-engine-widget
待 integration 模块产出正式 `wrapText()` 后,再把测试入口切到正式函数(不改变期望数据)。
## 4. 完成定义DoD
- Golden fixtures + 测试可在 CI 一键运行
- 至少包含 EN/TC 的基础样例与 3 类性质测试
- overview 更新记录变更文件

View File

@@ -0,0 +1,57 @@
# golden-tests子模块规范
## 子模块名称
golden-testsGolden 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

View File

@@ -0,0 +1,50 @@
# golden-tests任务清单
> 目标:建立 Golden Cases 与性质测试determinism/monotonic/maxLines回归体系保证算法演进可控。
## 0. 对齐与准备
- [x] 阅读并对齐口径
- [x] 阅读 `spec_kit/Text Wrap/modules/golden-tests/spec.md`
- [x] 阅读 `spec_kit/Text Wrap/modules/golden-tests/plan.md`
- [x] 阅读 `设计说明文档/文档换行算法.md` 的 16.1/16.2 章节
- [x] 创建客户端测试目录
- [x] 新建 `client/src/features/textWrap/golden/`
## 1. Golden fixtures
- [x] 新建 `client/src/features/textWrap/golden/fixtures.ts`
- [x] 定义 `GoldenCase` 类型text/lang/context/availableWidth/maxLines 等)
- [x] 补充 EN/TC 基础样例集合(首版最小可运行)
- [x] 对每个 case 写入 `expected.lines/expected.wrappedText`
## 2. 测试 harness临时串联
- [x] 在测试中实现 `wrapTextHarness()`(仅用于测试)
- [x] normalizeWhitespace
- [x] EN tokenizeEN / TC segmentGraphemes
- [x] 使用“全断点集合”1..N-1首版避免候选裁剪影响
- [x] APPsearchBestLayoutApp测量 mock=length
- [x] WIDGETsearchBestLayoutWidgetwidthMode=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] 写入变更文件清单

View File

@@ -0,0 +1,136 @@
# grapheme-segmentation技术计划
## 1. 计划目标
基于 `spec.md``设计说明文档/文档换行算法.md v1.2.1`在客户端侧JS/TS落地**可复用且确定性**的 TC grapheme cluster字符簇分割能力用于 Text Wrap 的 TC tokens 生成,确保:
- 不拆 surrogate pair、ZWJ、VS16、肤色修饰符、国旗regional indicator flags、组合字符`é`
- 同输入同输出clusters 内容与顺序完全一致)
- 输出边界可作为 TC 断点边界(后续模块仅在 cluster 边界断行)
- 提供可解释 meta采用何种策略、是否降级
## 2. 默认技术决策(本计划采用)
> 说明:本模块要解决的是“分割口径”,不引入排版/搜索逻辑。
- **优先策略**:若运行环境支持 `Intl.Segmenter`,优先使用:
- `new Intl.Segmenter('zh-Hant', { granularity: 'grapheme' })`
- 取其 `segment(text)``segment` 字段作为 clusters
- **兜底策略(两种实现路径,默认选 A**
- **方案 A推荐**:引入轻量依赖 `grapheme-splitter` 作为 fallback避免手写不完整的 Unicode 规则导致漏拆/误拆
- **方案 B无依赖兜底**:实现“最低可用”分割(满足文档列出的组合不拆),但不承诺覆盖所有 Unicode 边界规则(风险较高)
> 本计划默认采用 **方案 A**。若后续明确“禁止新增依赖”,再切换到方案 B 并补齐更多回归。
## 3. 目录与产物
本子模块目录:
- `spec_kit/Text Wrap/modules/grapheme-segmentation/spec.md`
- `spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md`(本文)
建议未来代码落位(实现阶段落地):
- `client/src/features/textWrap/grapheme/`
- `segmentGraphemes.ts`
- `strategies/intlSegmenter.ts`
- `strategies/fallback.ts`
- `__tests__/segmentGraphemes.test.ts`
## 4. API 设计(实现阶段的稳定契约)
实现一个最小可用纯函数:
- `segmentGraphemes(text: string, mode: 'PREFERRED' | 'FALLBACK') => { clusters: string[]; meta: { strategy: 'INTL_SEGMENTER' | 'FALLBACK'; hadFallback: boolean } }`
设计约束:
- `clusters.join('') === text`(不允许丢字符/改字符顺序)
- `clusters` 为空数组时必须是 `text==''`(不允许把空白当成 cluster 误输出)
## 5. 实现步骤(按落地顺序)
### 5.1 优先策略Intl.Segmenter
- 检测 `globalThis.Intl?.Segmenter` 是否可用
- 若可用且 `mode='PREFERRED'`
- 使用 `granularity='grapheme'` 分割
- 输出 `meta.strategy='INTL_SEGMENTER'``hadFallback=false`
注意:
- 需要确认 Expo/RN 的运行时是否始终具备 `Intl.Segmenter`(不同 JS 引擎/版本可能差异)
- 即使可用,也必须通过回归样例验证“不拆”要求
### 5.2 兜底策略Fallback
当出现以下任一情况时进入 fallback
- `mode='FALLBACK'`
- `Intl.Segmenter` 不存在
- `Intl.Segmenter` 运行抛错/返回异常结果(如空、丢字符)
#### 方案 A`grapheme-splitter`
- 新增依赖:`grapheme-splitter`
- 使用其分割能力输出 clusters
- 输出 `meta.strategy='FALLBACK'``hadFallback=true`
#### 方案 B最低可用手写规则仅在禁止依赖时启用
实现最低要求:
- 合并 surrogate pair
- 合并 ZWJ sequence`U+200D` 连接)
- 合并 variation selector`U+FE0F`
- 合并 skin tone modifier`U+1F3FB..U+1F3FF`
- 合并 regional indicator flags两两成对
- 合并组合字符combining marks与预组合等价形式至少覆盖 `e\u0301`
风险提示:
- 该实现容易漏掉其他扩展 grapheme cluster 规则;需要更高的测试覆盖与持续维护
## 6. 回归用例与测试计划Vitest
### 6.1 必须覆盖的样例(文档要求)
以下输入必须“不拆”为单个 cluster
- `👨‍👩‍👧‍👦`
- `🇸🇬`
- `👍🏽`
- `😮‍💨`
- `é`(至少覆盖 `e\u0301` 组合形式)
断言:
- `clusters.length === 1`
- `clusters[0] === input`
### 6.2 基础性质测试(建议)
- **可逆性**`clusters.join('') === input`
- **确定性**:同输入多次调用输出完全一致
- **空字符串**`'' -> []`
### 6.3 跨策略一致性(建议)
在支持 `Intl.Segmenter` 的环境中:
- 同一输入在 `PREFERRED``FALLBACK` 两种模式下输出 clusters 应一致
- 若出现差异,必须新增回归样例并明确差异原因(并在上层通过 configVersion 治理)
## 7. 性能与安全
- 单次分割复杂度应接近 \(O(n)\)
- 对超长文本(例如 > 2000 code units建议在上层模块触发裁剪或打点本模块仅保证不崩溃
- 任何异常必须被捕获并降级到 fallback保证“可用性优先”
## 8. 完成定义DoD
- `segmentGraphemes()` 在本地可运行,并通过必测回归样例
- 输出 meta 能区分 `INTL_SEGMENTER``FALLBACK`
- 单测覆盖:必测样例 + 可逆性 + 确定性
- 文档口径与实现一致不拆要求、fallback 触发条件、返回结构)

View File

@@ -0,0 +1,52 @@
# grapheme-segmentation子模块规范
## 子模块名称
grapheme-segmentationTC 字符簇分割)
## 目标描述
在 TC中文/繁中)场景下,统一“按 grapheme cluster字符簇切分”的实现口径确保
- 不拆分 surrogate pair代理对
- 不拆分 ZWJ 序列(家庭 emoji 等)
- 不拆分 variation selectorVS16 等)
- 不拆分 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`
- **不依赖其他模块**

View File

@@ -0,0 +1,126 @@
# grapheme-segmentation任务清单
> 对应计划:`spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md`
>
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
---
## 0. 任务标记规则
- 用勾选框标记执行状态:
- `[ ]` 未完成
- `[x]` 已完成
- 每个任务必须可独立验收(有明确产出与检查方式)。
- 所有代码注释必须为简体中文,且把“分割口径”写清楚,避免跨端实现漂移。
---
## 1. 前置检查(环境能力与策略选择)
- [x] 1.1 确认运行时是否支持 `Intl.Segmenter`Expo/RN 当前引擎)
- **方式**:在本地运行/测试环境中打印或断言 `globalThis.Intl?.Segmenter` 是否存在
- **验收**:记录结论:存在/不存在若不存在fallback 必须覆盖所有必测样例。
- [x] 1.2 确认 fallback 策略选择为“方案 A`grapheme-splitter`
- **要求**:若项目明确禁止新增依赖,需要在本任务中写明原因并切换到“方案 B手写最低可用同时补齐更高测试覆盖
- **验收**plan 与实际实现策略一致(不出现“文档写 A、代码做 B”的漂移
---
## 2. 依赖与目录骨架(客户端侧实现)
- [x] 2.1 新建目录 `client/src/features/textWrap/grapheme/`
- **产出**(建议文件):
- `segmentGraphemes.ts`(对外纯函数)
- `strategies/intlSegmenter.ts`
- `strategies/fallback.ts`
- `types.ts`(返回结构与 meta 类型)
- `__tests__/segmentGraphemes.test.ts`
- **验收**目录存在TS 可正常 import不报路径错误
- [x] 2.2(方案 A新增依赖 `grapheme-splitter` 并锁定到 `client/package.json`
- **验收**
- `npm install grapheme-splitter` 成功
- `npm test` 仍能通过(不破坏现有测试)
---
## 3. 纯函数实现(分割 + meta
- [x] 3.1 实现 `segmentGraphemes(text, mode)` 的返回契约
- **要求**
- 返回 `{ clusters, meta }`
- `clusters.join('') === text`(不丢字符/不改顺序)
- `text==''``clusters==[]`
- **验收**:为上述约束写入单测并通过。
- [x] 3.2 实现优先策略 `Intl.Segmenter``mode='PREFERRED'` 时优先)
- **要求**
- `meta.strategy='INTL_SEGMENTER'`
- `meta.hadFallback=false`
- **验收**:在支持该能力的环境中,至少 1 个常规输入能走到该策略(可通过 meta 断言)。
- [x] 3.3 实现 fallback 策略(满足所有必测“不拆”样例)
- **触发条件**(任一满足即 fallback
- `mode='FALLBACK'`
- `Intl.Segmenter` 不存在
- `Intl.Segmenter` 抛错/返回异常结果(空/丢字符)
- **要求**
- `meta.strategy='FALLBACK'`
- `meta.hadFallback=true`
- **验收**:必测样例全部通过(见 4.2)。
---
## 4. 单元测试Vitest
- [x] 4.1 基础性质测试
- **覆盖**
- 可逆性:`clusters.join('') === input`
- 确定性:同输入多次调用输出一致
- 空字符串:`'' -> []`
- **验收**:测试通过且不会出现偶现失败。
- [x] 4.2 必测回归样例(文档要求:每个都必须“不拆”为 1 个 cluster
- **样例**
- `👨‍👩‍👧‍👦`
- `🇸🇬`
- `👍🏽`
- `😮‍💨`
- `e\u0301`(组合字符形式)
- **断言**
- `clusters.length === 1`
- `clusters[0] === input`
- **验收**:在本地 `npm test` 中稳定通过。
- [x] 4.3 跨策略一致性测试(在支持 `Intl.Segmenter` 的环境中执行)
- **内容**:同一输入在 `PREFERRED` 与强制 `FALLBACK` 下输出 clusters 一致
- **验收**:一致;若不一致,必须新增回归样例并在文档中写明差异与治理方式(`configVersion`)。
---
## 5. 最终自检清单(合入前)
- [x] 5.1 `npm test` 通过(包含本模块新增用例)
- **验收**:不影响现有测试文件。
- [x] 5.2 `npx tsc --noEmit` 通过(或项目既有 TS 检查命令通过)
- **验收**:无类型错误。
- [x] 5.3 注释与口径自检(简体中文)
- **检查点**
- 明确“字符簇不拆”的边界意义(后续断点仅能在 clusters 边界)
- 明确 fallback 触发条件与 meta 含义
- **验收**:后续模块开发者只看代码也不会产生歧义。
---
## 6. 文档回写(任务清单执行完毕后必须做)
- [x] 6.1 在 `spec_kit/overview.md``Text Wrap` 条目下补充执行状态
- **建议写法**
- 增加一行:`- **已完成编码(阶段性)**grapheme-segmentationTC 字符簇分割)`
- **验收**overview 能反映该子模块已完成,便于全局追踪。

View File

@@ -0,0 +1,77 @@
# integration技术计划
## 1. 计划目标
实现对外统一入口 `wrapText()`(纯函数风格),把已完成的子模块串成可复用算法模块,供 HomeAPP与 WidgetWIDGET调用
- 参数口径统一:`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**
- ENtokenizeEN
- TCsegmentGraphemes → Token[]
3. **breakpoint-candidates**
- generateBreakpoints传入 constraints.forbiddenBreakRanges
4. **search**
- APPsearchBestLayoutAppDP+TopK测量优先
- WIDGETsearchBestLayoutWidgetBeam默认 APPROX可选 MEASURE
5. **overflow-fallback**
- 当搜索失败或未覆盖到 N按 overflowMode 执行ELLIPSIS/CLIP/SYSTEM_DEFAULT
6. **meta 汇总**
- breaks若可得
- scoreTopTermsdebug=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 更新记录变更文件

View File

@@ -0,0 +1,52 @@
# integration子模块规范
## 子模块名称
integrationHome / Widget 接入)
## 目标描述
`wrapText()` 模块以一致参数口径接入到两端渲染链路中,并定义 UI 侧对 meta 的处理规则,确保:
- Home 与 Widget 使用同一套配置/词库/版本号(`configVersion`
- 同场景下输出一致;不同测量能力导致差异时“可解释且可治理”
- SYSTEM_DEFAULT 的语义不被 UI 误用
## 输入/输出定义
### 输入
- HomeAPP侧输入
- `text/lang/availableWidth/maxLines/fontSpec/context='APP'`
- 可选:`constraints/overflowMode/lineMode/configVersion/debug`
- WidgetWIDGET侧输入
- `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`
- **被依赖**:无(最终落地层)

View File

@@ -0,0 +1,63 @@
# integration任务清单
> 目标:实现 `wrapText()` 统一入口并串联所有子模块,供 Home/Widget 使用。
## 0. 对齐与准备
- [x] 阅读并对齐口径
- [x] 阅读 `spec_kit/Text Wrap/modules/integration/spec.md`
- [x] 阅读 `spec_kit/Text Wrap/modules/integration/plan.md`
- [x] 阅读 `spec_kit/Text Wrap/spec.md` 的对外接口定义
## 1. 对外类型定义
- [x] 新建 `client/src/features/textWrap/types.ts`
- [x] 定义 `WrapTextInput`(与大 spec 对齐)
- [x] 定义 `WrapTextMeta`(包含 configVersion/fallback_type/overflow_type/reason/breaks/scoreTopTerms
- [x] 定义 `WrapTextOutput`
## 2. 实现 wrapText() 入口
- [x] 新建 `client/src/features/textWrap/wrapText.ts`
- [x] normalizeWhitespace固定策略 NORMALIZE
- [x] tokenize
- [x] ENtokenizeEN
- [x] TCsegmentGraphemes → Token[]
- [x] generateBreakpoints接入 forbiddenBreakRanges
- [x] 组装 scoring 的 lexicons
- [x] protectedPhrases 来自 constraints.protectedPhrases若有
- [x] 搜索:
- [x] APPsearchBestLayoutApp测量 mockablefontSpec 缺字段报错由 width-measurement 保证)
- [x] WIDGETsearchBestLayoutWidget默认 APPROX可选 MEASURE
- [x] 搜索失败/未覆盖 N
- [x] 调用 applyOverflowFallbackoverflowMode 默认APP=CLIPWIDGET=ELLIPSIS
- [x] 汇总输出 meta
- [x] configVersion
- [x] breaks若可得
- [x] scoreTopTermsdebug=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_DEFAULTmeta.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] 写入变更文件清单

View File

@@ -0,0 +1,109 @@
# overflow-fallback技术计划
## 1. 计划目标
实现统一的“溢出与兜底”策略模块,用于在以下情况给出确定性且可解释的返回:
- 搜索器无法在 `<=maxLines` 覆盖到结束边界 `N`
- `lineMode=FIXED` 需要刚好 `maxLines` 但无解
- 测量不可用或失败导致无法执行宽度派约束(尤其 Widget
支持三种模式:
- `ELLIPSIS`:最后一行加省略号并保证不超宽(必要时回退移除 token 再加)
- `CLIP`:截断到 `maxLines`(不加省略号)
- `SYSTEM_DEFAULT`:算法层不插入换行,交给系统排版(仅在 meta 标记)
## 2. 对外输入/输出(与 spec 对齐)
### 2.1 输入
- `tokens: Token[]`
- `partialLayout?: { breaks: number[]; lines: Array<{ start; end; text? }> }`
- 若提供:表示搜索器的 best-effort可能未覆盖到 N
- `overflowMode: 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'`
- `availableWidth: number`
- `maxLines: number`
- `lang: 'TC' | 'EN'`
- `context: 'APP' | 'WIDGET'`
- `ellipsisToken: string`(推荐 `"…"`,配置化)
- `measure?: { contextProfile; fontSpec; measureWidthImpl; widgetEnableMeasure? }`(用于“加省略号后再测量”)
- `reason: 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'TOO_LONG' | 'WIDOW' | 'PARTICLE' | string`
### 2.2 输出
统一返回:
- `result: { lines: string[]; wrappedText: string; meta: { fallback_type; overflow_type; reason } }`
其中:
- `fallback_type`: `NONE | RELAX_RULES | SYSTEM_DEFAULT`
- `overflow_type`: `NONE | ELLIPSIS | CLIP`
## 3. 核心行为细则(对应文档 12.x
### 3.1 overflow 判定
在本模块内不重新跑搜索,仅基于输入判断:
-`partialLayout` 覆盖到 `N`(即最后一行 `end==N`),则 overflow_type=NONE
- 否则为 overflow按 overflowMode 执行
### 3.2 SYSTEM_DEFAULT12.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 ELLIPSIS12.3/12.5
仅处理最后一行:
1. 选取 baseLines
- 优先使用 `partialLayout.lines`(若为空则把全文当单行)
- 截断到 `maxLines`(只在最后一行做 ellipsis
2. 清理规则(固定,确定性):
- 不输出 `" …"`:最后一行末尾空白先 trim
- 不输出 `",…"/"。…"`:若最后一行末尾是常见 TC 标点(`,。!?;:、`),则移除该标点再加 ellipsis
3. 宽度检查12.3
- 若提供测量能力且启用:
- 重新测量 `lastLine+ellipsisToken`
- 若超宽:按语言回退单位移除 token 再加省略号并重测
- EN按整词token
- TC按 graphemetoken
- 若测量不可用不做重测直接输出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_DEFAULTlines 单行meta.fallback_type=SYSTEM_DEFAULT
- CLIP超过 maxLines 时截断行数
- ELLIPSIS
- 末尾空白去除,不输出 `" …"`
- 末尾 TC 标点去除,不输出 `",…"`
- 测量超宽时按 token 回退直到不超宽(用 mock measureWidthImpl
## 6. 完成定义DoD
- 三种 overflowMode 行为稳定且可解释
- ELLIPSIS 的清理与回退测量实现完毕(可测)
- 输出 meta 字段满足文档 12/14 的治理需求

View File

@@ -0,0 +1,52 @@
# overflow-fallback子模块规范
## 子模块名称
overflow-fallback溢出与兜底
## 目标描述
统一定义“无解/溢出”时的行为与返回语义,确保:
- APP 与 WIDGET 在无法覆盖全文时表现一致且可解释
- ELLIPSIS 的字符、清理规则与再次测量约束统一
- SYSTEM_DEFAULT 的返回语义明确(算法层不擅自改变 UI 行为)
## 输入/输出定义
### 输入
- `tokens: Token[]`
- `partialBest?: Layout`(搜索器找到的最佳 partial 或 best effort
- `overflowMode: 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'`
- `availableWidth: number`
- `maxLines: number`
- `lang: 'TC' | 'EN'`
- `context: 'APP' | 'WIDGET'`
- `ellipsisToken: string`(推荐 `"…"`,必须配置化)
- `measureWidth?: fn`(用于“加省略号后再测量”)
### 输出
- `result: { lines: string[]; wrappedText: string; meta: { fallback_type: 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT'; overflow_type: 'NONE' | 'ELLIPSIS' | 'CLIP'; reason: string } }`
## 验收标准(可验证)
- **overflow 判定一致**
- 搜索无法在 `<=maxLines` 覆盖到结束边界 N即为 overflow
- **ELLIPSIS 规则一致**
- 仅处理最后一行
- 加省略号后必须再次检查不超宽(必要时回退移除 token 再加省略号)
- EN 回退单位为整词TC 回退单位为 grapheme
- 清理规则:不输出 `" …"`;不输出 `",…"/"。…"`(按配置决定是否移除末尾标点再加省略号,但必须固定)
- **SYSTEM_DEFAULT 语义一致**
- 仍返回 `lines/wrappedText`
- 仅在 meta 标记 `fallback_type=SYSTEM_DEFAULT`
- UI 可依据 meta 决定是否完全交给系统排版
- **确定性**:同输入同输出(含 meta
## 依赖与关联
- **依赖**`core-contract`(重组)、`width-measurement`(测量/降级)、`scoring-tiebreak`(避免截断短语的策略可复用评分)
- **被依赖**`search-engine-app``search-engine-widget``integration``golden-tests`

View File

@@ -0,0 +1,65 @@
# overflow-fallback任务清单
> 目标:统一实现 `ELLIPSIS/CLIP/SYSTEM_DEFAULT` 的溢出与兜底语义(文档 12.x输出稳定可解释的 meta。
## 0. 对齐与准备
- [x] 阅读并对齐口径
- [x] 阅读 `spec_kit/Text Wrap/modules/overflow-fallback/spec.md`
- [x] 阅读 `spec_kit/Text Wrap/modules/overflow-fallback/plan.md`
- [x] 阅读 `设计说明文档/文档换行算法.md` 的 12.x/14 章节
- [x] 创建客户端模块目录
- [x] 新建 `client/src/features/textWrap/overflow/`
## 1. 类型与对外接口
- [x] 新建 `client/src/features/textWrap/overflow/types.ts`
- [x] 定义 `OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT'`
- [x] 定义 `FallbackType = 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT'`
- [x] 定义 `OverflowType = 'NONE' | 'ELLIPSIS' | 'CLIP'`
- [x] 定义 `OverflowReason`至少包含NO_CANDIDATE/WIDTH_UNKNOWN/TOO_LONG/WIDOW/PARTICLE
- [x] 定义 `PartialLayoutInput``breaks` + `lines[{start,end,text?}]`
- [x] 定义 `ApplyOverflowFallbackInput/Result`
- [x] 新建 `client/src/features/textWrap/overflow/index.ts`
- [x] 统一导出 types 与入口 `applyOverflowFallback()`
## 2. ELLIPSIS 核心逻辑
- [x] 新建 `client/src/features/textWrap/overflow/ellipsis.ts`
- [x] 实现 `cleanLineBeforeEllipsis(line, lang)`
- [x] trim 末尾空白,避免 `" …"`
- [x] TC若末尾是 `,。!?;:、`,移除该标点,避免 `",…"`
- [x] 实现 `applyEllipsisToLastLine(...)`
- [x] 仅处理最后一行
- [x] EN 回退单位=整词TC 回退单位=grapheme
- [x] 若提供测量能力:加省略号后必须重新测量,超宽则循环回退再测量
- [x] 若测量不可用:直接输出(确定性)
## 3. 入口applyOverflowFallback
- [x] 新建 `client/src/features/textWrap/overflow/fallback.ts`
- [x] 实现 `applyOverflowFallback(input)`
- [x] overflow 判定partialLayout 是否覆盖到 N
- [x] `SYSTEM_DEFAULT`12.4A):返回单行原文 + meta.fallback_type=SYSTEM_DEFAULT
- [x] `CLIP`:截断到 maxLines
- [x] `ELLIPSIS`:调用 ellipsis 逻辑
- [x] meta 输出:`fallback_type/overflow_type/reason`
## 4. 单测Vitest
- [x] 新建 `client/src/features/textWrap/overflow/__tests__/overflowFallback.test.ts`
- [x] SYSTEM_DEFAULT 语义
- [x] CLIP 截断行为
- [x] ELLIPSIS 清理规则(不输出 `" …"`、不输出 `",…"`
- [x] ELLIPSIS 测量回退mock measureWidthImpl让超宽时按 token 回退直到不超宽
## 5. 收尾
- [x] 跑测试与类型检查
- [x] `npm test`
- [x] `npx tsc --noEmit`
- [x] 将本 `tasks.md` 全部勾选完成
- [x] 更新 `spec_kit/overview.md`
- [x] 标记 `overflow-fallback` 已完成编码(阶段性)
- [x] 写入变更文件清单

View File

@@ -0,0 +1,179 @@
# scoring-tiebreak技术计划
## 1. 计划目标
基于 `spec.md``设计说明文档/文档换行算法.md v1.2.1`,在客户端实现可解释的评分模型与确定性 tie-break保证
- **整数评分**:所有评分项使用整数,避免浮点误差
- **权重写死客户端**:首版权重/词表内置在客户端代码(后续如需灰度,用 `configVersion` 管理)
- **短语匹配口径固定**emotionPhrases/protectedPhrases 必须是“连续 token 完全匹配”
- **更长短语优先**:采用方案 A——在触发拆分惩罚时按“被拆分短语长度”追加惩罚确定性
- **debug 可解释**:输出 score breakdowndebug=true 时输出 Top-3按规则优先级排序不是按 |delta|
- **tieKey 固定**:严格按文档第 11 节顺序构造 tieKeyWidget approx mode 仍使用数值 width可为近似单位确保 tie-break 可用
本模块不负责搜索DP/Beam只负责对“候选 layout”打分并给出可比较的 tieKey。
## 2. 默认技术决策(本计划采用)
### 2.1 评分数值体系
- 全部评分项使用 **整数**
- 不引入 EPS因为不使用浮点分数相等即为相等
### 2.2 权重与默认值(写死客户端)
首版采用文档 10.3 的建议量级(可在实现中集中定义为常量/配置对象):
- `P_EMOTION_SPLIT = 10000`
- `P_PROTECTED_SPLIT = 10000`
- `P_WIDOW_WORD = 800`
- `P_WIDOW_LINE = 500`
- `P_SHORT_LASTLINE = 300`
- `P_PARTICLE_ISO = 200`
- `R_PUNCT_BREAK = 80`
- `R_SHIFT_BREAK = 60`
- `R_ACCUM_BREAK = 40`
- `R_SELF_BREAK = 20`
- `P_OVER_MAXLEN = 30`
- `P_TOO_SHORT = 10`
> 说明:权重必须集中在一个文件,禁止散落在各函数内;后续调整通过 `configVersion` 记录。
### 2.3 最小词表TC/EN
首版提供最小可用词表(来源:文档第 7 节):
- TC
- shift`但/可是/然而/却/只是/偏偏`
- accum`已经/一直/曾经/终于/还是/到现在`
- self`你/我/自己/我们/别人`
- emotionWords可选`累/痛/怕/孤单/委屈/撑/崩溃/放弃`
- EN全词等值匹配使用 core-contract 的 normalize 规则):
- shift`but/yet/so`and 轻量可选)
- accum`already/still/even/just/really`
- self`you/yourself/me/we`
- emotionWords可选`tired/afraid/lonely/hurt/overwhelmed/give up`
emotionPhrasesTC/EN 与 protectedPhrases
- 首版允许为空数组(先把匹配与惩罚机制写死)
- 若业务侧已有短语清单,后续直接填充并通过 Golden Cases 回归
### 2.4 宽度派tieKey 使用 width
- layoutCandidate.lines[*].width 字段作为 tieKey 的 width 来源
- 在 width unknown/approx mode 时,上游仍需给出“数值宽度”(例如用 wordCount/graphemeCount 作为 width 近似值),保证 tie-break 可执行
## 3. 目录与产物(客户端侧实现)
建议代码落位:
- `client/src/features/textWrap/scoring/`
- `types.ts`Layout/LineSegment/ScoreBreakdown
- `weights.ts`(权重常量,写死)
- `lexicons.ts`(最小词表,写死)
- `phraseMatch.ts`(连续 token 完全匹配 + span 预计算)
- `score.ts`(按 10.1 顺序累计分数)
- `tieKey.ts`(按 11 节构造 tieKey
- `debug.ts`Top-3 提取与排序)
- `index.ts`
- `__tests__/scoringTiebreak.test.ts`
## 4. 输入/输出契约(实现阶段写死)
### 4.1 输入
- `layoutCandidate`
- `breaks: number[]`
- `lines: Array<{ start; end; text; width; tokenCount; charCount }>`
- `lang: 'TC' | 'EN'`
- `context: 'APP' | 'WIDGET'`
- `config`
- `weights: Record<string, number>`(首版由内置默认值生成)
- `idealWidthRatio: { APP: 0.90; WIDGET: 0.95 }`(来自文档 8.3A
- `ellipsisToken: "…"`(用于溢出模块;本模块只保留配置入口)
- `particleWhitelistTC: string[]`(语尾语助词白名单;惩罚减半)
- `lexicons`(首版使用内置最小词表;允许调用方覆盖)
### 4.2 输出
- `scoredLayout`
- `score: number`
- `flags: { emotionSplit?: boolean; overflowed?: boolean; fallback?: boolean }`
- `tieKey: Array<number | string>`
- `scoreBreakdown?: { total: number; terms: Array<{ key: string; delta: number; detail?: any }> }`
## 5. 实现步骤(按落地顺序)
### 5.1 Phrase 匹配与 span 预计算(连续 token 完全匹配)
实现口径(必须):
- EN对输入 tokens 使用 `core-contract``normalizeWhitespace + tokenizeEN`
- TC对输入 tokens 使用 `grapheme-segmentation` 的 clusters
- 对 phrase
- EN先 normalizeWhitespace再按空格切分为词序列不做 substring
- TC按 grapheme clusters 切分
- 匹配方式:在 token 序列中寻找 **连续区间** 完全匹配
- 输出 spans`{ start, end, length }`end 为半开区间)
### 5.2 评分项(严格按 10.1 顺序累计)
按文档 10.1 固定顺序计算并记录 breakdown
1. **情绪短语/受保护短语拆分惩罚**(最高优先)
- 若某 phrase span 被 breaks 拆到不同的行:
- `score -= P_*`
- 并按方案 A 追加惩罚:`score -= phraseLength`(或 `k * phraseLength`k 写死为 1确保确定性
- flags`emotionSplit=true`(或 protectedSplit 也可复用同一 flag/额外字段,需写死)
2. **行长度ideal/over/too short**
- `idealWidth = availableWidth * idealWidthRatio[context]`availableWidth 由上游传入)
- 以 width-based 作为主要项:`score += -abs(line.width - idealWidth)`
- overMaxLen/tooShort 按配置阈值与权重惩罚(首版可先按文档默认区间落地)
3. **Widow / 单字行惩罚**
4. **TC 标点断点奖励**
5. **Shift/Accum/Self 断点奖励(含 EN 行首/行尾感知)**
6. **尾行过短惩罚SHORT_LASTLINE**
> 注意:必须保留每一项的确定性记录,不允许“覆盖”前序裁决结果(见文档 10.1A)。
### 5.3 Debug Top-3 输出
- 输出结构:`ScoreBreakdown = { total, terms[] }`
- debug=true 时只保留 Top-3 term但排序规则必须按优先级10.1 顺序),同优先级再按出现顺序稳定排序
### 5.4 tieKey 构造(按 11 节固定顺序)
按文档顺序生成 tieKey比较时逐项比较
1. `emotionSplit=false` 优先(可用 `0/1`
2. `overflowed=false` 优先
3. `lastLineWidth` 更大优先(因此 tieKey 可存 `-lastLineWidth` 或比较时反向)
4. 行宽分布更均匀优先(`maxWidth-minWidth` 更小优先)
5. 断点更接近理想切分点优先(距离和更小)
6. breaks 字典序更靠前优先(使用 core-contract 的 breaks 比较规则)
## 6. 测试计划Vitest
### 6.1 Phrase 匹配
- EN确保全词等值匹配`but,` 命中 `but``rebuttal` 不命中)
- TCemoji/组合字符的 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 稳定性

View File

@@ -0,0 +1,54 @@
# scoring-tiebreak子模块规范
## 子模块名称
scoring-tiebreak评分模型与确定性裁决
## 目标描述
定义并实现可解释的评分模型Scoring Model与固定 tie-break 规则用于在“合法断点组合”中选出最优布局layout并保证跨端完全一致。
本模块不负责搜索DP/Beam但负责
- 每行/每断点的评分项计算顺序(必须固定)
- 情绪短语/受保护短语的匹配口径(连续 token 完全匹配)
- Debug breakdown 的结构与 Top-3 输出排序(按优先级而非 |delta|
- tieKey 的构造与比较规则(含 breaks 字典序定义)
## 输入/输出定义
### 输入
- `layoutCandidate: { breaks: number[]; lines: Array<{ start: number; end: number; text: string; width: number; tokenCount: number; charCount: number }> }`
- `lang: 'TC' | 'EN'`
- `context: 'APP' | 'WIDGET'`
- `config: { weights: Record<string, number>; idealWidthRatio: { APP: number; WIDGET: number }; ellipsisToken: string; particleWhitelistTC: string[]; eps?: number }`
- `lexicons: { emotionPhrasesTC: string[]; emotionPhrasesEN: string[]; protectedPhrases?: string[]; shiftWordsTC: string[]; shiftWordsEN: string[]; accumWordsTC: string[]; accumWordsEN: string[]; selfWordsTC: string[]; selfWordsEN: string[]; emotionWordsTC?: string[]; emotionWordsEN?: string[] }`
### 输出
- `scoredLayout: { score: number; flags: { emotionSplit?: boolean; overflowed?: boolean; fallback?: boolean }; tieKey: Array<number | string>; scoreBreakdown?: { total: number; terms: Array<{ key: string; delta: number; detail?: any }> } }`
## 验收标准(可验证)
- **评分项顺序固定**:评分必须严格按算法文档 10.1 的顺序计算并记录(不可重排)
- **短语匹配口径正确**
- emotionPhrase/protectedPhrases连续 token 完全匹配(不允许跳 token、不允许模糊
- EN 关键词命中遵循 `core-contract` 的“全词等值匹配”
- **冲突裁决一致**
- emotionPhrases 与 protectedPhrases 同级强保护
- 同等条件下更长短语优先(需在惩罚或 tieKey 中体现,且确定性)
- **奖励折减规则一致**
- EmotionWord 与 Accum 同时命中时Accum 奖励按 0.5 倍(先算 EmotionWord 再折减)
- **tie-break 一致**
- 规则顺序固定emotionSplit/overflowed/lastLineWidth/均衡度/理想距离/breaks 字典序)
- breaks 字典序定义:逐项比较;公共前缀相同则更短数组更小
- **debug Top-3 输出排序一致**
- debug 模式关键项按优先级输出(非 |delta|
- **整数分优先**:尽量使用整数评分;若使用浮点必须定义 EPS并用 `abs(a-b)<=EPS` 判断近似相等
## 依赖与关联
- **依赖**`core-contract`索引、文本重组、EN 命中工具)
- **被依赖**`search-engine-app``search-engine-widget``overflow-fallback``golden-tests`

View File

@@ -0,0 +1,122 @@
# scoring-tiebreak任务清单
> 目标:实现“评分模型 + 确定性 tie-break”用于对候选 layout 打分并产出可比较的 `tieKey`。
> 约束:**整数评分**、**权重/最小词表写死客户端**、短语匹配为**连续 token 完全匹配**、debug Top-3 按**优先级顺序**输出。
## 0. 准备与对齐
- [x] 阅读并对齐口径
- [x] 复核 `spec_kit/Text Wrap/modules/scoring-tiebreak/spec.md`
- [x] 复核 `spec_kit/Text Wrap/modules/scoring-tiebreak/plan.md`
- [x] 复核 `设计说明文档/文档换行算法.md` 中 10.x评分与 11tie-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/ENshift/accum/selfemotionWords 可选)
- [x] 提供 `DEFAULT_LEXICONS`(只读)与可选的 `mergeLexicons(overrides)`
- [x] 明确 `emotionPhrasesTC/EN``protectedPhrases` 首版可为空数组
## 3. Phrase 匹配(连续 token 完全匹配)
- [x] 新建 `client/src/features/textWrap/scoring/phraseMatch.ts`
- [x] 定义 `PhraseSpan = { start: number; end: number; length: number }`end 半开)
- [x] 实现 EN phrase 预处理
- [x] 使用 `core-contract``normalizeWhitespace` + `tokenizeEN`
- [x] phrase 输入为原始字符串:先 normalizeWhitespace再按空格切分为词序列
- [x] 实现 TC phrase 预处理
- [x] 使用 `grapheme-segmentation``segmentGraphemes` 得到 clusters
- [x] phrase 输入为原始字符串:按 grapheme clusters 切分
- [x] 实现连续区间完全匹配查找(禁止跳 token / 禁止模糊)
- [x] 输出所有命中的 spans稳定顺序按 start 升序start 相同按 length 降序)
- [x] 实现 `isSpanSplitByBreaks(span, breaks)`:判断 span 是否跨行(被拆分)
- [x] 单测覆盖:
- [x] EN`but,` 命中 `but``rebuttal` 不命中 `but`
- [x] TC包含 emoji/组合字符时不应被拆(依赖 grapheme 模块;这里只验证匹配结果可逆)
## 4. 评分实现(按 10.1 固定顺序,整数累计)
- [x] 新建 `client/src/features/textWrap/scoring/score.ts`
- [x] 定义 `scoreLayout(args)`入参包含layoutCandidate、lang、context、config、lexicons、availableWidth、debug?
- [x] 实现 score 累计(必须严格按文档 10.1 的顺序)
- [x] 1) 情绪短语/受保护短语拆分惩罚(最高优先)
- [x] 匹配 emotionPhrases + protectedPhrases spans
- [x] 若 span 被拆分:`score -= P_*`
- [x] 采用方案 A追加“更长短语优先”惩罚`score -= phraseLength`k=1 写死)
- [x] 设置 `flags.emotionSplit=true`(如需区分 protected 可扩展 detail但保持确定性
- [x] 2) 行长度ideal/overMaxLen/tooShort
- [x] `idealWidth = availableWidth * idealWidthRatio[context]`(取整规则必须固定:建议 `Math.round` 并写注释)
- [x] width-based 主项:`score += -abs(line.width - idealWidth)`(确保整数)
- [x] over/tooShort 按权重惩罚(阈值若来自文档,集中定义常量,禁止散落)
- [x] 3) Widow / 单字行惩罚(基于 tokenCount/charCount 口径写死)
- [x] 4) TC 标点断点奖励(使用断点 meta/kind 或基于行首字符推断;口径写注释)
- [x] 5) Shift/Accum/Self 断点奖励TC/EN 词表)
- [x] EN 必须使用 `core-contract` 的全词等值匹配(复用 `normalizeENKeyword/matchENKeyword`
- [x] 实现 spec 要求的折减EmotionWord 与 Accum 同时命中时Accum 奖励按 0.5 倍
- [x] 因为整体使用整数:折减采用“整除/四舍五入”的固定规则(建议 `Math.floor(reward/2)` 并写注释)
- [x] 6) 尾行过短惩罚SHORT_LASTLINE
- [x] 记录 `scoreBreakdown`
- [x] 每个 term 写入 `{ key, delta, detail }`
- [x] terms 的产生顺序必须与 10.1 顺序一致(用于 debug 输出排序)
- [x] debug Top-3不按 |delta|,按优先级)
- [x]`debug=true`:只保留前 3 个“优先级最高的关键项”
- [x] 规则:先按 10.1 顺序,必要时同优先级保持稳定(按出现顺序)
- [x] 单测覆盖:
- [x] 评分顺序固定:构造 layout 触发 EmotionSplit断言 breakdown 第一项为对应 key
- [x] 整数性:断言所有 delta 与 total 都是整数
## 5. tieKey 构造与比较(按 11 节固定顺序)
- [x] 新建 `client/src/features/textWrap/scoring/tieKey.ts`
- [x] 实现 `buildTieKey(scoredLayout, layoutCandidate, config)`,严格按 11 节顺序产出 `Array<number | string>`
- [x] emotionSplitfalse 优先)
- [x] overflowedfalse 优先)
- [x] lastLineWidth更大优先采用 `-lastLineWidth` 进入 tieKey或在比较函数中反向比较二选一且写注释
- [x] 宽度分布均衡度:`maxWidth - minWidth`(更小优先)
- [x] 与理想切分点距离(更小优先;距离定义需与 breakpoints 模块/上游一致)
- [x] breaks 字典序(复用 `core-contract` 的 breaks 比较函数)
- [x] 单测覆盖:
- [x] 同分 layout通过 tieKey 能稳定选出同一个最优
- [x] Widget approx即使 width 近似wordCount/graphemeCounttieKey 仍可比较(不抛错)
## 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 两套 tokenizationEN 用 coreTC 用 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 中记录本次变更文件清单(至少包含新增的客户端文件与测试文件)

View File

@@ -0,0 +1,104 @@
# search-engine-app技术计划
## 1. 计划目标
**APP** 场景实现确定性的“断点组合搜索器”,使用 **DP + TopK** 生成候选 layout 并选出最优解:
- 输入:`tokens[]``breakpoints[]``availableWidth``maxLines``lineMode`
- 过程DP 状态转移 + TopK 维护(去重、排序、确定性 tie-break
- 评分:调用 `scoring-tiebreak``scoreLayout` + `buildTieKey`
- 输出:`bestLayout`breaks/lines/wrappedText/meta或返回 “无解/超长/宽度不可用” 的可解释原因overflow-fallback 模块将统一处理最终语义)
## 2. 约束与确定性口径
### 2.1 确定性(必须)
同输入(含 configVersion/权重/词表/断点列表)必须同输出:
- DP 遍历顺序固定pos 升序、linesUsed 升序)
- nextPos 枚举顺序固定(按 `pos` 升序;最后补一个 `N` 结束边界)
- TopK 的排序固定:`score` 降序;再按 `tieKey` 逐项比较11 节);再按 `breaks` 字典序11.0A
- 去重固定:同 `breaks` 序列只保留最优score 更大;若相同用 tieKey
### 2.2 复杂度(必须可控)
遵循文档 9.3 建议:
- 默认 `TopK=10`
- 默认 `maxLines<=3`(或 4
- 超长输入触发 `TOO_LONG`TC>60 grapheme 或 EN>30 words首版写死后续 configVersion 管理)
## 3. DP + TopK 设计(对应文档 9.1
### 3.1 状态定义
`N=tokens.length`token 边界为 `pos in [0..N]`
- `dp[pos][linesUsed] = TopK partial layouts ending at token boundary pos`
- partial layout 至少包含:
- `pos`:当前结束位置
- `breaks: number[]`:已选择的断点序列(升序)
- `lines: LineInfo[]`已生成的行信息start/end/text/width/tokenCount/charCount
- `score: number` + `tieKey: number[]`(由 scoring-tiebreak 构造)
### 3.2 转移(生成下一行)
从状态 `(pos, linesUsed)` 选择 `nextPos` 生成新行区间 `[pos..nextPos)`
- nextPos 取值来自 `breakpoints``b.pos``b.pos > pos`,并按 `pos` 升序枚举
- 必须额外允许 `nextPos = N`(结束边界)
- 每次转移需:
- 构造 lineText使用 `core-contract/joinTokens`
- 测量 lineWidth优先使用 `width-measurement/measureSliceWidthCached`(切片缓存),若不可用返回 `WIDTH_UNKNOWN`
- 应用硬约束过滤(见 3.3
- 计算新 layout 的评分与 tieKey调用 scoring-tiebreak
- 插入 `dp[nextPos][linesUsed+1]` 并维护 TopK去重、排序
### 3.3 硬约束(本模块先落地最小集)
为了让 DP 行为稳定且可解释,首版在搜索层执行以下硬约束(其余放到评分):
- **H1 不超宽**:若 lineWidth 可用且 `lineWidth > availableWidth`,该转移无效
- **H5 最大行数**`linesUsed+1 <= maxLines`
- **禁止空行**`nextPos > pos`(区间非空)
- **TC 禁止行首标点H6**
-`lang=TC``pos>0``tokens[pos]` 属于 `tcPunctuations`,则该断点不可用(跳过)
> 说明protectedPhrases 的“span 内断点剔除”属于候选断点优化(可选),首版不在搜索层做剔除,依赖 scoring 的强惩罚淘汰(文档 6.2A / 10.2A)。
### 3.4 结束条件与 lineMode
- 正常结束:到达 `pos=N`
- `lineMode=AUTO`
-`dp[N][1..maxLines]` 里选最优
- `lineMode=FIXED`
- 优先从 `dp[N][==maxLines]` 里选最优
- 若无解:返回 “无解” 的原因与必要 meta由 overflow-fallback 统一降级链路与打点)
## 4. 代码落位(客户端)
建议目录:
- `client/src/features/textWrap/searchApp/`
- `types.ts`
- `dpTopK.ts`DP 主流程 + TopK 维护)
- `topK.ts`(去重、插入、排序、截断)
- `constraints.ts`H1/H6/TOO_LONG 判定)
- `index.ts`
- `__tests__/searchApp.test.ts`
## 5. 测试计划Vitest
- **确定性**:同输入运行 3 次输出完全一致breaks/lines/wrappedText
- **TopK 去重**:构造两条路径得到相同 breaks断言只保留一个且为最优
- **lineMode=FIXED**:有解时必须返回刚好 `maxLines`;无解时返回 reason例如 `NO_CANDIDATE`
- **TC 行首标点**:构造 tokens 在断点后行首为 ``,断言该转移被禁止
- **TOO_LONG**TC>60 或 EN>30 时直接返回 `TOO_LONG`
## 6. 完成定义DoD
- DP+TopK 可运行,且确定性通过单测
- 使用 `scoring-tiebreak` 进行评分与 tieKey 裁决
- 搜索层硬约束最小集落地H1/H6/空行/maxLines
- 无解/超长/宽度不可用时返回明确 reason不直接在本模块做最终 overflow

Some files were not shown because too many files have changed in this diff Show More