Compare commits
5 Commits
Lei-0209
...
b5532df161
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5532df161 | ||
| 5515726465 | |||
|
|
ee2d9f44ea | ||
| 4578d503e7 | |||
|
|
f03d36b5e9 |
@@ -1,4 +1,4 @@
|
|||||||
当前有一个很大的 spec.md(大需求规范),需要按业务逻辑拆分成多个子模块规范。
|
当前有一个很大的 spec.md(大需求规范),需要按业务逻辑合理拆分成多个子模块规范。
|
||||||
|
|
||||||
请按以下规则拆分:
|
请按以下规则拆分:
|
||||||
|
|
||||||
|
|||||||
@@ -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} 已上线"
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,9 @@ 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';
|
||||||
|
import { ensureDailyWidgetRecoUpToDate } from '@/src/modules/dailyWidgetReco';
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -97,6 +110,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 +192,133 @@ 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',
|
||||||
|
// Home:采用“更偏好语气停顿/更好看”的排版风格微调(不影响算法默认 v1)
|
||||||
|
configVersion: 'v1-home',
|
||||||
|
debug: __DEV__,
|
||||||
|
fontSpec,
|
||||||
|
contextProfile: `APP|${Platform.OS}|home|${lang}`,
|
||||||
|
measureWidthImpl: defaultMeasureWidthImpl,
|
||||||
|
scoringOverrides:
|
||||||
|
lang === 'TC'
|
||||||
|
? {
|
||||||
|
// 更偏好在逗号/句号等处断行(即便宽度允许也不一定要塞满)
|
||||||
|
weights: { R_PUNCT_BREAK: 180 },
|
||||||
|
// 让“理想行宽”更短,避免宽屏下过度延后断行
|
||||||
|
idealWidthRatio: { APP: 0.82 },
|
||||||
|
// 更宽容短行(尤其是第一行在标点处停顿)
|
||||||
|
minPreferredRatio: 0.45,
|
||||||
|
shortLastLineRatio: 0.45,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -248,6 +391,11 @@ export default function HomeScreen() {
|
|||||||
setIndex(0);
|
setIndex(0);
|
||||||
fetchNewFeed();
|
fetchNewFeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Widget:前台辅助刷新(尽力而为)
|
||||||
|
// - 写入 App Group 的 dailyReco 缓存
|
||||||
|
// - 生成 wrapped_text_by_family,供 Widget 直接渲染
|
||||||
|
ensureDailyWidgetRecoUpToDate({ reason: 'home_focus' }).catch(() => {});
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -315,13 +463,41 @@ export default function HomeScreen() {
|
|||||||
});
|
});
|
||||||
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
|
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
|
||||||
|
|
||||||
|
// 切换到上一条文案的统一动画逻辑(下滑触发)
|
||||||
|
const triggerPrevContent = useCallback(() => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
|
||||||
|
// 1. 当前文案向下移动并消失
|
||||||
|
translateY.value = withTiming(40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||||
|
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||||
|
if (finished) {
|
||||||
|
// 2. 切换数据索引(循环回退)
|
||||||
|
const nextIndex = index - 1 < 0 ? Math.max(0, currentFeed.length - 1) : index - 1;
|
||||||
|
runOnJS(setIndex)(nextIndex);
|
||||||
|
runOnJS(setLikeFilled)(false);
|
||||||
|
|
||||||
|
// 3. 准备上一条文案:先瞬移到上方 40pt
|
||||||
|
translateY.value = -40;
|
||||||
|
|
||||||
|
// 4. 上一条文案向下移动到原位并显现
|
||||||
|
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
||||||
|
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
||||||
|
if (finished) {
|
||||||
|
runOnJS(setBusy)(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [busy, index, currentFeed.length, translateY, opacity]);
|
||||||
|
|
||||||
const lastTapRef = useRef<number>(0);
|
const lastTapRef = useRef<number>(0);
|
||||||
|
|
||||||
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
|
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
|
||||||
const handlersRef = useRef({ onPressLike, triggerNextContent });
|
const handlersRef = useRef({ onPressLike, triggerNextContent, triggerPrevContent });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handlersRef.current = { onPressLike, triggerNextContent };
|
handlersRef.current = { onPressLike, triggerNextContent, triggerPrevContent };
|
||||||
}, [onPressLike, triggerNextContent]);
|
}, [onPressLike, triggerNextContent, triggerPrevContent]);
|
||||||
|
|
||||||
// 使用系统自带的 PanResponder 代替第三方手势库
|
// 使用系统自带的 PanResponder 代替第三方手势库
|
||||||
const panResponder = useRef(
|
const panResponder = useRef(
|
||||||
@@ -346,9 +522,11 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
lastTapRef.current = now;
|
lastTapRef.current = now;
|
||||||
|
|
||||||
// 2. 上滑逻辑判定
|
// 2. 上滑/下滑逻辑判定
|
||||||
if (gestureState.dy < -50) { // 上滑超过 50pt
|
if (gestureState.dy < -50) { // 上滑超过 50pt
|
||||||
runOnJS(handlersRef.current.triggerNextContent)();
|
runOnJS(handlersRef.current.triggerNextContent)();
|
||||||
|
} else if (gestureState.dy > 50) { // 下滑超过 50pt
|
||||||
|
runOnJS(handlersRef.current.triggerPrevContent)();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -420,19 +598,25 @@ export default function HomeScreen() {
|
|||||||
onPress={() => setThemeOpen(true)}
|
onPress={() => setThemeOpen(true)}
|
||||||
accessibilityLabel={t('home.theme')}
|
accessibilityLabel={t('home.theme')}
|
||||||
>
|
>
|
||||||
<ThemeIcon width={18} height={18} />
|
<ThemeIcon width={20} height={20} />
|
||||||
</CircleIconButton>
|
</CircleIconButton>
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
onPress={() => setProfileOpen(true)}
|
onPress={() => setProfileOpen(true)}
|
||||||
accessibilityLabel={t('home.profile')}
|
accessibilityLabel={t('home.profile')}
|
||||||
>
|
>
|
||||||
<MyIcon width={18} height={18} />
|
<MyIcon width={20} height={20} />
|
||||||
</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>
|
||||||
|
|
||||||
@@ -442,15 +626,16 @@ export default function HomeScreen() {
|
|||||||
onPress={onPressLike}
|
onPress={onPressLike}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t('home.like')}
|
accessibilityLabel={t('home.like')}
|
||||||
hitSlop={20}
|
// 稍微增大可点击区域,提升单手操作成功率
|
||||||
|
hitSlop={24}
|
||||||
style={styles.reactionInner}
|
style={styles.reactionInner}
|
||||||
>
|
>
|
||||||
{likeFilled ? (
|
{likeFilled ? (
|
||||||
<LikeFilledIcon width={35} height={36} color="#EA6969" />
|
<LikeFilledIcon width={40} height={41} color="#EA6969" />
|
||||||
) : (
|
) : (
|
||||||
<LikeIcon
|
<LikeIcon
|
||||||
width={35}
|
width={40}
|
||||||
height={36}
|
height={41}
|
||||||
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
|
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -480,7 +665,8 @@ function CircleIconButton({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
hitSlop={10}
|
// 稍微增大可点击区域,提升易用性
|
||||||
|
hitSlop={14}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
style={styles.circleBtn}
|
style={styles.circleBtn}
|
||||||
@@ -505,9 +691,9 @@ const styles = StyleSheet.create({
|
|||||||
zIndex: 30,
|
zIndex: 30,
|
||||||
},
|
},
|
||||||
circleBtn: {
|
circleBtn: {
|
||||||
width: 34,
|
width: 40,
|
||||||
height: 34,
|
height: 40,
|
||||||
borderRadius: 17,
|
borderRadius: 20,
|
||||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
BIN
client/assets/images/Screen_page.png
Normal file
BIN
client/assets/images/Screen_page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
@@ -553,6 +553,8 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const currentLang = i18n.language;
|
const currentLang = i18n.language;
|
||||||
|
// 需求:个人主页弹窗「小工具」页暂时隐藏锁屏小工具说明/入口
|
||||||
|
const showLockScreenWidget = false;
|
||||||
|
|
||||||
// 根据语言选择图片
|
// 根据语言选择图片
|
||||||
const widget1 = currentLang === 'en'
|
const widget1 = currentLang === 'en'
|
||||||
@@ -570,10 +572,12 @@ function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<View style={styles.widgetScroll}>
|
<View style={styles.widgetScroll}>
|
||||||
|
{showLockScreenWidget ? (
|
||||||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||||
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
|
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
|
||||||
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
|
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||||
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" />
|
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function WidgetModal({ visible, onClose }: Props) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetModal visible={visible} title={t('profile.widget')} onClose={onClose}>
|
<SheetModal visible={visible} title={t('widget.howToTitle')} onClose={onClose}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<PreviewCard label={t('widget.lockScreen')}>
|
<PreviewCard label={t('widget.lockScreen')}>
|
||||||
|
|||||||
21
client/eas.json
Normal file
21
client/eas.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2540,30 +2540,30 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
|
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
|
||||||
EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
|
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
|
||||||
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||||
EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b
|
EXManifests: 83ef0844fcf06d6099b12a7bdbd7d36fc0e1dd16
|
||||||
EXNotifications: 9eec98712cc814ceff916d876cb53859003b0597
|
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
|
||||||
Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710
|
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
|
||||||
expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d
|
expo-dev-client: 12ef7d5b14d93e309922acea78dcd851db583a87
|
||||||
expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3
|
expo-dev-launcher: 47994056008ffdc30a6a5e328a375b3e30a8db05
|
||||||
expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d
|
expo-dev-menu: ea4fb803ace52e60d7cd8060c7cd379612a140b2
|
||||||
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
||||||
ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
|
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
||||||
ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322
|
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
|
||||||
ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a
|
ExpoDevice: 0773c782b055558ca9b40b74aa4a8133a66cd0d2
|
||||||
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
|
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
||||||
ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47
|
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
||||||
ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780
|
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
||||||
ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296
|
ExpoKeepAwake: 44bf6715bc1d2ddb17afe19d927cd039cda123f0
|
||||||
ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58
|
ExpoLinearGradient: 814a21fc4056c3cf606e4f19e31e47074c5b5a86
|
||||||
ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37
|
ExpoLinking: ebf543fd411d56375cb4eee07f6ab4e31c7ad959
|
||||||
ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd
|
ExpoLocalization: 6ac6f326210f0a3141ef6f58ab8f8f4ed003b485
|
||||||
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
|
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
|
||||||
ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d
|
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
|
||||||
ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52
|
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
|
||||||
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
|
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b
|
||||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||||
@@ -2571,72 +2571,72 @@ SPEC CHECKSUMS:
|
|||||||
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
||||||
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
|
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
|
||||||
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
|
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
|
||||||
React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
|
React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90
|
||||||
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
|
React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151
|
||||||
React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382
|
React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac
|
||||||
React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da
|
React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c
|
||||||
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
|
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
|
||||||
React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833
|
React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c
|
||||||
React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66
|
React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d
|
||||||
React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2
|
React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983
|
||||||
React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a
|
React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4
|
||||||
React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8
|
React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86
|
||||||
React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171
|
React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d
|
||||||
React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f
|
React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28
|
||||||
React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6
|
React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24
|
||||||
React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b
|
React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf
|
||||||
React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76
|
React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc
|
||||||
React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e
|
React-ImageManager: 396128004783fc510e629124dce682d38d1088e7
|
||||||
React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d
|
React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795
|
||||||
React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de
|
React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0
|
||||||
React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3
|
React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669
|
||||||
React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f
|
React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51
|
||||||
React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89
|
React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0
|
||||||
React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f
|
React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb
|
||||||
React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e
|
React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270
|
||||||
React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a
|
React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca
|
||||||
React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf
|
React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a
|
||||||
React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28
|
React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd
|
||||||
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
|
React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108
|
||||||
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
|
React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c
|
||||||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd
|
||||||
React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa
|
React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd
|
||||||
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
|
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
|
||||||
React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4
|
React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f
|
||||||
React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526
|
React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490
|
||||||
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
|
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
|
||||||
React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e
|
React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af
|
||||||
React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016
|
React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28
|
||||||
React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53
|
React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264
|
||||||
React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea
|
React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6
|
||||||
React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797
|
React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266
|
||||||
React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32
|
React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac
|
||||||
React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b
|
React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31
|
||||||
React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20
|
React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000
|
||||||
React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418
|
React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b
|
||||||
React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035
|
React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a
|
||||||
React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b
|
React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461
|
||||||
React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25
|
React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e
|
||||||
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
|
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
|
||||||
React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453
|
React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490
|
||||||
React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89
|
React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812
|
||||||
React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4
|
React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613
|
||||||
React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0
|
React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4
|
||||||
React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e
|
React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c
|
||||||
React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6
|
React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562
|
||||||
React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f
|
React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7
|
||||||
React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
|
React-timing: 03c7217455d2bff459b27a3811be25796b600f47
|
||||||
React-utils: 6e2035b53d087927768649a11a26c4e092448e34
|
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
|
||||||
ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
|
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
|
||||||
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
|
ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f
|
||||||
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
|
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
||||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||||
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
||||||
RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f
|
RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3
|
||||||
RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
|
RNReanimated: 43f611f1c85c90e0273df7399bf1536f8e2bd125
|
||||||
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
||||||
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
|
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
||||||
RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0
|
RNWorklets: 28ee7370ca8da356fcc914e3e68b97e9752196d2
|
||||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||||
|
|
||||||
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
|
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 56;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -11,12 +11,13 @@
|
|||||||
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 */; };
|
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 */; };
|
||||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||||
|
C0A1B2C3D4E5F60718293A4E /* Screen_page.png in Resources */ = {isa = PBXBuildFile; fileRef = C0A1B2C3D4E5F60718293A4D /* Screen_page.png */; };
|
||||||
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||||
EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */; };
|
EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */; };
|
||||||
EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
@@ -53,11 +54,12 @@
|
|||||||
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>"; };
|
||||||
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>"; };
|
||||||
|
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Screen_page.png; path = ../assets/images/Screen_page.png; 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; };
|
||||||
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "情绪小组件Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "情绪小组件Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -72,7 +74,7 @@
|
|||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
|
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
EmotionWidget.swift,
|
EmotionWidget.swift,
|
||||||
@@ -83,18 +85,7 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
|
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
|
||||||
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 */
|
||||||
@@ -131,6 +122,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;
|
||||||
@@ -210,7 +202,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>";
|
||||||
@@ -298,6 +290,7 @@
|
|||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
"zh-Hant",
|
||||||
);
|
);
|
||||||
mainGroup = 83CBB9F61A601CBA00E9B192;
|
mainGroup = 83CBB9F61A601CBA00E9B192;
|
||||||
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
||||||
@@ -318,6 +311,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;
|
||||||
@@ -477,7 +471,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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ private let keyWidgetConfig = "widget.config.v1"
|
|||||||
private let keyWidgetUserProfile = "widget.userProfile.v1_2"
|
private let keyWidgetUserProfile = "widget.userProfile.v1_2"
|
||||||
private let keyWidgetDailyReco = "widget.dailyReco.v1"
|
private let keyWidgetDailyReco = "widget.dailyReco.v1"
|
||||||
|
|
||||||
private let fallbackTextTC = "你已经很努力了,今天也值得被温柔对待。"
|
private let fallbackTextTC = "你已經很努力了,今天也值得被溫柔對待。"
|
||||||
private let fallbackTextEN = "You’ve been doing great — you deserve kindness today."
|
private let fallbackTextEN = "You’ve been doing great — you deserve kindness today."
|
||||||
|
|
||||||
private func defaults() -> UserDefaults? {
|
private func defaults() -> UserDefaults? {
|
||||||
@@ -29,17 +29,24 @@ private func localDayKey(_ date: Date = Date()) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveLang() -> String {
|
private func resolveLang() -> String {
|
||||||
// 仅支持 en/tc
|
// 仅支持 en/tc:根据设备语言选择
|
||||||
let preferred = Locale.preferredLanguages.first?.lowercased() ?? "en"
|
// - 传统中文(Hant / TW / HK / MO)=> tc
|
||||||
return preferred.hasPrefix("zh") ? "tc" : "en"
|
// - 其他语言(含简中 zh-Hans / zh-CN)=> en(默认)
|
||||||
|
let preferred = (Locale.preferredLanguages.first ?? "en").lowercased()
|
||||||
|
if preferred.hasPrefix("zh-hant") { return "tc" }
|
||||||
|
if preferred.hasPrefix("zh-tw") { return "tc" }
|
||||||
|
if preferred.hasPrefix("zh-hk") { return "tc" }
|
||||||
|
if preferred.hasPrefix("zh-mo") { return "tc" }
|
||||||
|
return "en"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveTitle(lang: String) -> String {
|
private func resolveTitle(lang: String) -> String {
|
||||||
lang == "en" ? "Mindfulness" : "正念"
|
// 需求:品牌文案「正念」统一改为 Hey Mama
|
||||||
|
return "Hey Mama"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveFooterHint(lang: String) -> String {
|
private func resolveFooterHint(lang: String) -> String {
|
||||||
lang == "en" ? "Tap to open the app" : "点我回到 App"
|
lang == "en" ? "Tap to open the app" : "點我回到 App"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func joinUrl(base: String, path: String) -> String {
|
private func joinUrl(base: String, path: String) -> String {
|
||||||
@@ -76,13 +83,41 @@ private func writeJsonDict(_ dict: [String: Any], forKey key: String) {
|
|||||||
defaults()?.set(raw, forKey: key)
|
defaults()?.set(raw, forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func readCachedText() -> (dayKey: String?, lang: String, text: String)? {
|
private func readCachedText(family: WidgetFamily) -> (dayKey: String?, lang: String, text: String)? {
|
||||||
guard let d = readJsonDict(forKey: keyWidgetDailyReco) else { return nil }
|
guard let d = readJsonDict(forKey: keyWidgetDailyReco) else { return nil }
|
||||||
let lang = (d["lang"] as? String) ?? resolveLang()
|
let lang = (d["lang"] as? String) ?? resolveLang()
|
||||||
let dayKey = d["day_key"] as? String
|
let dayKey = d["day_key"] as? String
|
||||||
if let item = d["item"] as? [String: Any], let text = item["text"] as? String, !text.isEmpty {
|
if let item = d["item"] as? [String: Any] {
|
||||||
|
if let text = pickWidgetText(item: item, family: family), !text.isEmpty {
|
||||||
return (dayKey: dayKey, lang: lang, text: text)
|
return (dayKey: dayKey, lang: lang, text: text)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func familyKey(_ family: WidgetFamily) -> String {
|
||||||
|
switch family {
|
||||||
|
case .systemSmall:
|
||||||
|
return "small"
|
||||||
|
case .systemMedium:
|
||||||
|
return "medium"
|
||||||
|
case .systemLarge:
|
||||||
|
return "large"
|
||||||
|
default:
|
||||||
|
return "small"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pickWidgetText(item: [String: Any], family: WidgetFamily?) -> String? {
|
||||||
|
// 优先使用 App 侧预换行的结果(wrapped_text_by_family),否则回退 raw text
|
||||||
|
if let family = family,
|
||||||
|
let wrappedByFamily = item["wrapped_text_by_family"] as? [String: Any] {
|
||||||
|
let key = familyKey(family)
|
||||||
|
if let v = wrappedByFamily[key] as? String, !v.isEmpty {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let raw = item["text"] as? String, !raw.isEmpty { return raw }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +209,7 @@ struct EmotionProvider: TimelineProvider {
|
|||||||
let today = localDayKey(Date())
|
let today = localDayKey(Date())
|
||||||
|
|
||||||
// 1) 今日缓存优先
|
// 1) 今日缓存优先
|
||||||
if let cached = readCachedText(), cached.dayKey == today {
|
if let cached = readCachedText(family: context.family), cached.dayKey == today {
|
||||||
let entry = EmotionEntry(
|
let entry = EmotionEntry(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
lang: cached.lang,
|
lang: cached.lang,
|
||||||
@@ -201,7 +236,7 @@ struct EmotionProvider: TimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3) 网络失败:用最近缓存或兜底
|
// 3) 网络失败:用最近缓存或兜底
|
||||||
if let cached = readCachedText() {
|
if let cached = readCachedText(family: context.family) {
|
||||||
let entry = EmotionEntry(
|
let entry = EmotionEntry(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
lang: cached.lang,
|
lang: cached.lang,
|
||||||
@@ -332,8 +367,9 @@ struct EmotionWidget: Widget {
|
|||||||
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
||||||
EmotionWidgetView(entry: entry)
|
EmotionWidgetView(entry: entry)
|
||||||
}
|
}
|
||||||
.configurationDisplayName("情绪小组件")
|
// 名称/描述:支持多语言(使用 Widget Extension 自己的 Localizable.strings)
|
||||||
.description("一段温柔提醒,陪你回到当下。")
|
.configurationDisplayName("WIDGET_DISPLAY_NAME")
|
||||||
|
.description("WIDGET_DESCRIPTION")
|
||||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
client/ios/情绪小组件/en.lproj/Localizable.strings
Normal file
3
client/ios/情绪小组件/en.lproj/Localizable.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"WIDGET_DISPLAY_NAME" = "Emotion Widget";
|
||||||
|
"WIDGET_DESCRIPTION" = "A gentle reminder to return to the present.";
|
||||||
|
|
||||||
3
client/ios/情绪小组件/zh-Hant.lproj/Localizable.strings
Normal file
3
client/ios/情绪小组件/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"WIDGET_DISPLAY_NAME" = "情緒小組件";
|
||||||
|
"WIDGET_DESCRIPTION" = "一段溫柔提醒,陪你回到當下。";
|
||||||
|
|
||||||
15
client/package-lock.json
generated
15
client/package-lock.json
generated
@@ -25,6 +25,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",
|
||||||
@@ -36,6 +37,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"
|
||||||
},
|
},
|
||||||
@@ -6958,6 +6960,11 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/grapheme-splitter": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -9708,6 +9715,14 @@
|
|||||||
"react-native-svg": ">=12.0.0"
|
"react-native-svg": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-text-size": {
|
||||||
|
"version": "4.0.0-rc.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-native-text-size/-/react-native-text-size-4.0.0-rc.1.tgz",
|
||||||
|
"integrity": "sha512-CysqjU2jK6Yc+a+kEI222pUyTY2ywcU2HqbFqf1KHymW6OPTdvBBHqbEJKL0QiLhQaFYDbqicM+h990s9TP00g==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": ">=0.59.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-web": {
|
"node_modules/react-native-web": {
|
||||||
"version": "0.21.2",
|
"version": "0.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||||
|
|||||||
@@ -31,6 +31,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",
|
||||||
@@ -42,6 +43,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { wrapText } from '../index';
|
||||||
|
|
||||||
|
describe('textWrap integration wrapText', () => {
|
||||||
|
it('SYSTEM_DEFAULT:meta 标记 SYSTEM_DEFAULT 且仍返回 lines/wrappedText', async () => {
|
||||||
|
const res = await wrapText({
|
||||||
|
text: 'I am so tired',
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 1,
|
||||||
|
maxLines: 1,
|
||||||
|
overflowMode: 'SYSTEM_DEFAULT',
|
||||||
|
// 不提供测量能力,逼迫走兜底
|
||||||
|
fontSpec: null,
|
||||||
|
measureWidthImpl: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.lines.length).toBeGreaterThan(0);
|
||||||
|
expect(res.wrappedText.length).toBeGreaterThan(0);
|
||||||
|
expect(res.meta?.fallback_type).toBe('SYSTEM_DEFAULT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { generateBreakpoints } from '../index';
|
||||||
|
|
||||||
|
function t(text: string, start: number): { text: string; start: number; end: number } {
|
||||||
|
return { text, start, end: start + text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap breakpoint-candidates', () => {
|
||||||
|
it('EN: tokens=[I,am,so,tired] -> pos=[1,2,3](升序)', () => {
|
||||||
|
const tokens = [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)];
|
||||||
|
const { breakpoints, meta } = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'EN',
|
||||||
|
maxLines: 2,
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [',', '。'], balanceRange: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(breakpoints.map((b) => b.pos)).toEqual([1, 2, 3]);
|
||||||
|
expect(meta.originalCount).toBe(3);
|
||||||
|
expect(meta.finalCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC: 标点后断点(,)应生成 pos=i+1', () => {
|
||||||
|
const tokens = [t('我', 0), t('好', 1), t('累', 2), t(',', 3), t('😮💨', 4)];
|
||||||
|
const { breakpoints } = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 2,
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(breakpoints.some((b) => b.kind === 'PUNCT' && b.pos === 4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC: forbiddenBreakRanges(闭区间)命中必须剔除(含 start/end)', () => {
|
||||||
|
const tokens = [t('我', 0), t('好', 1), t('累', 2), t(',', 3), t('😮💨', 4)];
|
||||||
|
const { breakpoints } = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 2,
|
||||||
|
constraints: { forbiddenBreakRanges: [{ start: 4, end: 4 }] },
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(breakpoints.some((b) => b.pos === 4)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC: 去重规则:同 pos 优先保留 priority 更高者;同 priority 按 kind 序', () => {
|
||||||
|
// 构造:同一个 pos=2 同时来自 SPACE(priority=20) 和 BALANCE(priority=5),必须保留 SPACE
|
||||||
|
const tokens = [t('我', 0), t(' ', 1), t('好', 2)];
|
||||||
|
const { breakpoints } = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 2,
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [], balanceRange: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const b2 = breakpoints.find((b) => b.pos === 2);
|
||||||
|
expect(b2?.kind).toBe('SPACE');
|
||||||
|
expect(b2?.priority).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC: 裁剪上限生效且输出按 pos 升序(确定性)', () => {
|
||||||
|
const tokens = Array.from({ length: 60 }).map((_, i) => t('哈', i));
|
||||||
|
const res = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 3,
|
||||||
|
config: { tcMaxCandidateBreaks: 8, tcPunctuations: [], balanceRange: 6 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.breakpoints.length).toBe(8);
|
||||||
|
// pos 升序
|
||||||
|
for (let i = 1; i < res.breakpoints.length; i++) {
|
||||||
|
expect(res.breakpoints[i]!.pos).toBeGreaterThanOrEqual(res.breakpoints[i - 1]!.pos);
|
||||||
|
}
|
||||||
|
// 确定性
|
||||||
|
const res2 = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 3,
|
||||||
|
config: { tcMaxCandidateBreaks: 8, tcPunctuations: [], balanceRange: 6 },
|
||||||
|
});
|
||||||
|
expect(res).toEqual(res2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('边界:N<=1 时不输出任何候选断点', () => {
|
||||||
|
const { breakpoints } = generateBreakpoints({
|
||||||
|
tokens: [t('我', 0)] as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 2,
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 3 },
|
||||||
|
});
|
||||||
|
expect(breakpoints).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
22
client/src/features/textWrap/breakpoints/enCandidates.ts
Normal file
22
client/src/features/textWrap/breakpoints/enCandidates.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Breakpoint } from './types';
|
||||||
|
import type { Token } from '../core/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EN 候选断点生成(极简派)
|
||||||
|
*
|
||||||
|
* 口径:
|
||||||
|
* - tokens 仅为 WORD(不包含 SPACE token)
|
||||||
|
* - 候选断点只生成在 `pos ∈ [1, N-1]`
|
||||||
|
* - kind 固定为 SPACE,priority 固定为 10
|
||||||
|
*/
|
||||||
|
export function generateEnCandidates(tokens: Token[]): Breakpoint[] {
|
||||||
|
const n = tokens.length;
|
||||||
|
if (n <= 1) return [];
|
||||||
|
|
||||||
|
const out: Breakpoint[] = [];
|
||||||
|
for (let pos = 1; pos <= n - 1; pos++) {
|
||||||
|
out.push({ pos, kind: 'SPACE', priority: 10 });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
89
client/src/features/textWrap/breakpoints/filterAndDedup.ts
Normal file
89
client/src/features/textWrap/breakpoints/filterAndDedup.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Breakpoint, BreakpointKind, BreakpointMeta, BreakpointConstraints } from './types';
|
||||||
|
|
||||||
|
const KIND_ORDER: Record<BreakpointKind, number> = {
|
||||||
|
PUNCT: 0,
|
||||||
|
SPACE: 1,
|
||||||
|
BALANCE: 2,
|
||||||
|
OTHER: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
function betterBreakpoint(a: Breakpoint, b: Breakpoint): Breakpoint {
|
||||||
|
if (a.priority !== b.priority) return a.priority > b.priority ? a : b;
|
||||||
|
const ka = KIND_ORDER[a.kind] ?? 999;
|
||||||
|
const kb = KIND_ORDER[b.kind] ?? 999;
|
||||||
|
if (ka !== kb) return ka < kb ? a : b;
|
||||||
|
// 完全相同优先级时,稳定选择 pos 更小者(但同 pos 才会进入该比较)
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* forbiddenBreakRanges 过滤(闭区间口径)
|
||||||
|
* - start <= pos && pos <= end 命中则剔除
|
||||||
|
*/
|
||||||
|
export function filterForbiddenBreakRanges(
|
||||||
|
breakpoints: Breakpoint[],
|
||||||
|
constraints: BreakpointConstraints | undefined
|
||||||
|
): Breakpoint[] {
|
||||||
|
const ranges = constraints?.forbiddenBreakRanges;
|
||||||
|
if (!ranges || ranges.length === 0) return breakpoints;
|
||||||
|
|
||||||
|
return breakpoints.filter((bp) => {
|
||||||
|
for (const r of ranges) {
|
||||||
|
const s = r.start | 0;
|
||||||
|
const e = r.end | 0;
|
||||||
|
if (s <= bp.pos && bp.pos <= e) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去重:同 pos 只保留一个 breakpoint(priority 更高者优先;同 priority 按 kind 固定序)
|
||||||
|
*/
|
||||||
|
export function dedupByPos(breakpoints: Breakpoint[]): Breakpoint[] {
|
||||||
|
const map = new Map<number, Breakpoint>();
|
||||||
|
for (const bp of breakpoints) {
|
||||||
|
const prev = map.get(bp.pos);
|
||||||
|
if (!prev) {
|
||||||
|
map.set(bp.pos, bp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.set(bp.pos, betterBreakpoint(prev, bp));
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distanceToNearestIdeal(pos: number, ideals: number[] | undefined): number {
|
||||||
|
if (!ideals || ideals.length === 0) return 1_000_000_000;
|
||||||
|
let best = 1_000_000_000;
|
||||||
|
for (const p of ideals) {
|
||||||
|
const d = Math.abs(pos - p);
|
||||||
|
if (d < best) best = d;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TC 裁剪(确定性排序后截断)
|
||||||
|
* - priority desc
|
||||||
|
* - distToIdeal asc
|
||||||
|
* - pos asc
|
||||||
|
*/
|
||||||
|
export function pruneTcCandidates(breakpoints: Breakpoint[], ideals: number[], tcMaxCandidateBreaks: number): Breakpoint[] {
|
||||||
|
if (breakpoints.length <= tcMaxCandidateBreaks) return breakpoints;
|
||||||
|
|
||||||
|
const sorted = [...breakpoints].sort((a, b) => {
|
||||||
|
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||||
|
const da = distanceToNearestIdeal(a.pos, ideals);
|
||||||
|
const db = distanceToNearestIdeal(b.pos, ideals);
|
||||||
|
if (da !== db) return da - db;
|
||||||
|
return a.pos - b.pos;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted.slice(0, tcMaxCandidateBreaks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMeta(originalCount: number, finalCount: number): BreakpointMeta {
|
||||||
|
return { originalCount, finalCount, pruned: finalCount < originalCount };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Breakpoint, BreakpointMeta, GenerateBreakpointsInput } from './types';
|
||||||
|
import { generateEnCandidates } from './enCandidates';
|
||||||
|
import { generateTcCandidates } from './tcCandidates';
|
||||||
|
import { buildMeta, dedupByPos, filterForbiddenBreakRanges, pruneTcCandidates } from './filterAndDedup';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成候选断点(确定性)
|
||||||
|
*
|
||||||
|
* 统一口径:
|
||||||
|
* - 只输出 `pos ∈ [1, N-1]`
|
||||||
|
* - forbiddenBreakRanges 使用闭区间过滤:start <= pos && pos <= end
|
||||||
|
* - 去重按 priority 优先;priority 相同按 kind 固定序:PUNCT > SPACE > BALANCE > OTHER
|
||||||
|
* - TC 超过上限时按固定排序裁剪,然后最终按 pos 升序输出
|
||||||
|
*/
|
||||||
|
export function generateBreakpoints(input: GenerateBreakpointsInput): { breakpoints: Breakpoint[]; meta: BreakpointMeta } {
|
||||||
|
const tokens = input.tokens ?? [];
|
||||||
|
const n = tokens.length;
|
||||||
|
const lang = input.lang;
|
||||||
|
|
||||||
|
if (n <= 1) {
|
||||||
|
return { breakpoints: [], meta: buildMeta(0, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: Breakpoint[] = [];
|
||||||
|
let ideals: number[] = [];
|
||||||
|
|
||||||
|
if (lang === 'EN') {
|
||||||
|
raw = generateEnCandidates(tokens);
|
||||||
|
} else {
|
||||||
|
const out = generateTcCandidates(tokens, input.maxLines, {
|
||||||
|
tcPunctuations: input.config.tcPunctuations,
|
||||||
|
balanceRange: input.config.balanceRange,
|
||||||
|
});
|
||||||
|
raw = out.candidates;
|
||||||
|
ideals = out.idealPositions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalCount = raw.length;
|
||||||
|
|
||||||
|
// 过滤 forbiddenBreakRanges(闭区间)
|
||||||
|
let filtered = filterForbiddenBreakRanges(raw, input.constraints);
|
||||||
|
|
||||||
|
// 去重(同 pos 保留最佳)
|
||||||
|
filtered = dedupByPos(filtered);
|
||||||
|
|
||||||
|
// TC 裁剪(规模上限)
|
||||||
|
if (lang === 'TC') {
|
||||||
|
const max = Math.max(0, input.config.tcMaxCandidateBreaks | 0);
|
||||||
|
if (max > 0) {
|
||||||
|
filtered = pruneTcCandidates(filtered, ideals, max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最终按 pos 升序输出
|
||||||
|
filtered.sort((a, b) => a.pos - b.pos);
|
||||||
|
|
||||||
|
return { breakpoints: filtered, meta: buildMeta(originalCount, filtered.length) };
|
||||||
|
}
|
||||||
|
|
||||||
11
client/src/features/textWrap/breakpoints/index.ts
Normal file
11
client/src/features/textWrap/breakpoints/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type {
|
||||||
|
Breakpoint,
|
||||||
|
BreakpointConfig,
|
||||||
|
BreakpointConstraints,
|
||||||
|
BreakpointKind,
|
||||||
|
BreakpointMeta,
|
||||||
|
GenerateBreakpointsInput,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { generateBreakpoints } from './generateBreakpoints';
|
||||||
|
|
||||||
78
client/src/features/textWrap/breakpoints/tcCandidates.ts
Normal file
78
client/src/features/textWrap/breakpoints/tcCandidates.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Breakpoint } from './types';
|
||||||
|
import type { Token } from '../core/types';
|
||||||
|
|
||||||
|
type TcCandidateConfig = {
|
||||||
|
tcPunctuations: string[];
|
||||||
|
balanceRange: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 BALANCE 的 idealPos 列表(确定性简化版)。
|
||||||
|
*
|
||||||
|
* - N=tokens.length
|
||||||
|
* - targetLines=min(maxLines,N)
|
||||||
|
* - lineIndex in 1..targetLines-1:
|
||||||
|
* idealPos=round(N*lineIndex/targetLines)
|
||||||
|
*/
|
||||||
|
export function computeTcIdealPositions(tokens: Token[], maxLines: number): number[] {
|
||||||
|
const n = tokens.length;
|
||||||
|
const targetLines = Math.max(1, Math.min(maxLines | 0, n));
|
||||||
|
const ideals: number[] = [];
|
||||||
|
|
||||||
|
for (let lineIndex = 1; lineIndex <= targetLines - 1; lineIndex++) {
|
||||||
|
const idealPos = Math.round((n * lineIndex) / targetLines);
|
||||||
|
ideals.push(idealPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重并排序(稳定)
|
||||||
|
ideals.sort((a, b) => a - b);
|
||||||
|
return ideals.filter((v, idx) => idx === 0 || v !== ideals[idx - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TC 候选断点生成:
|
||||||
|
* - 标点后:kind=PUNCT,priority=30
|
||||||
|
* - 空格后:kind=SPACE,priority=20
|
||||||
|
* - BALANCE:kind=BALANCE,priority=5(围绕 idealPos ± balanceRange)
|
||||||
|
*
|
||||||
|
* 注意:BALANCE 断点允许生成在短语 span 内,是否可用交给评分阶段强惩罚淘汰。
|
||||||
|
*/
|
||||||
|
export function generateTcCandidates(tokens: Token[], maxLines: number, config: TcCandidateConfig): { candidates: Breakpoint[]; idealPositions: number[] } {
|
||||||
|
const n = tokens.length;
|
||||||
|
if (n <= 1) return { candidates: [], idealPositions: [] };
|
||||||
|
|
||||||
|
const punctSet = new Set(config.tcPunctuations);
|
||||||
|
const candidates: Breakpoint[] = [];
|
||||||
|
|
||||||
|
// PUNCT / SPACE(扫描 token)
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t = tokens[i]?.text ?? '';
|
||||||
|
const pos = i + 1;
|
||||||
|
|
||||||
|
// 只允许行内断点
|
||||||
|
if (pos < 1 || pos > n - 1) continue;
|
||||||
|
|
||||||
|
if (punctSet.has(t)) {
|
||||||
|
candidates.push({ pos, kind: 'PUNCT', priority: 30 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t === ' ') {
|
||||||
|
candidates.push({ pos, kind: 'SPACE', priority: 20 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BALANCE
|
||||||
|
const idealPositions = computeTcIdealPositions(tokens, maxLines);
|
||||||
|
const range = Math.max(0, config.balanceRange | 0);
|
||||||
|
if (range > 0 && idealPositions.length > 0) {
|
||||||
|
for (const ideal of idealPositions) {
|
||||||
|
for (let pos = ideal - range; pos <= ideal + range; pos++) {
|
||||||
|
if (pos < 1 || pos > n - 1) continue;
|
||||||
|
candidates.push({ pos, kind: 'BALANCE', priority: 5 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { candidates, idealPositions };
|
||||||
|
}
|
||||||
|
|
||||||
37
client/src/features/textWrap/breakpoints/types.ts
Normal file
37
client/src/features/textWrap/breakpoints/types.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
|
||||||
|
export type BreakpointKind = 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER';
|
||||||
|
|
||||||
|
export type Breakpoint = {
|
||||||
|
/** 断点位置(token 边界):切分为 [0..pos) + [pos..N) */
|
||||||
|
pos: number;
|
||||||
|
kind: BreakpointKind;
|
||||||
|
/** 候选优先级(用于去重/裁剪阶段),越大越优先 */
|
||||||
|
priority: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BreakpointMeta = {
|
||||||
|
pruned: boolean;
|
||||||
|
originalCount: number;
|
||||||
|
finalCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BreakpointConstraints = {
|
||||||
|
protectedPhrases?: string[];
|
||||||
|
forbiddenBreakRanges?: Array<{ start: number; end: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BreakpointConfig = {
|
||||||
|
tcMaxCandidateBreaks: number;
|
||||||
|
tcPunctuations: string[];
|
||||||
|
balanceRange: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateBreakpointsInput = {
|
||||||
|
tokens: Token[];
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
maxLines: number;
|
||||||
|
constraints?: BreakpointConstraints;
|
||||||
|
config: BreakpointConfig;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_PUNCTUATION_STRIP_SET_EN,
|
||||||
|
compareBreaksLexicographically,
|
||||||
|
joinTokens,
|
||||||
|
matchENKeyword,
|
||||||
|
normalizeENKeyword,
|
||||||
|
normalizeWhitespace,
|
||||||
|
tokenizeEN,
|
||||||
|
} from '../index';
|
||||||
|
|
||||||
|
describe('textWrap core-contract', () => {
|
||||||
|
it('normalizeWhitespace(NORMALIZE): 折叠空白 + 去首尾', () => {
|
||||||
|
const out = normalizeWhitespace(' a b \n c ', 'NORMALIZE');
|
||||||
|
expect(out.normalizedText).toBe('a b c');
|
||||||
|
expect(out.hadMultiWhitespace).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizeEN: 只按空白分词(极简派,不拆标点)', () => {
|
||||||
|
const { normalizedText } = normalizeWhitespace('I am so tired.', 'NORMALIZE');
|
||||||
|
const tokens = tokenizeEN(normalizedText);
|
||||||
|
expect(tokens.map((t) => t.text)).toEqual(['I', 'am', 'so', 'tired.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joinTokens: 默认用单空格重组', () => {
|
||||||
|
const { normalizedText } = normalizeWhitespace('I am so tired', 'NORMALIZE');
|
||||||
|
const tokens = tokenizeEN(normalizedText);
|
||||||
|
expect(joinTokens(tokens, 0, 2)).toBe('I am');
|
||||||
|
expect(joinTokens(tokens, 2, 4)).toBe('so tired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizeENKeyword/matchENKeyword: 全词等值匹配,不做 substring', () => {
|
||||||
|
const config = { punctuationStripSetEN: DEFAULT_PUNCTUATION_STRIP_SET_EN };
|
||||||
|
|
||||||
|
expect(normalizeENKeyword('BUT,', config)).toBe('but');
|
||||||
|
expect(matchENKeyword('but,', 'but', config)).toBe(true);
|
||||||
|
expect(matchENKeyword('rebuttal', 'but', config)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compareBreaksLexicographically: 字典序 + 短数组优先', () => {
|
||||||
|
expect(compareBreaksLexicographically([2], [3])).toBeLessThan(0);
|
||||||
|
expect(compareBreaksLexicographically([2], [2, 5])).toBeLessThan(0);
|
||||||
|
expect(compareBreaksLexicographically([2, 3], [2])).toBeGreaterThan(0);
|
||||||
|
expect(compareBreaksLexicographically([2, 3], [2, 3])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
25
client/src/features/textWrap/core/compare.ts
Normal file
25
client/src/features/textWrap/core/compare.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* breaks[] 字典序比较(确定性口径)
|
||||||
|
*
|
||||||
|
* 规则(必须):
|
||||||
|
* - 从 index=0 起逐项比较:
|
||||||
|
* - 首个不同元素更小者视为更小
|
||||||
|
* - 若公共前缀完全相同:
|
||||||
|
* - 更短的数组视为更小
|
||||||
|
*/
|
||||||
|
export function compareBreaksLexicographically(a: number[], b: number[]): number {
|
||||||
|
const aa = Array.isArray(a) ? a : [];
|
||||||
|
const bb = Array.isArray(b) ? b : [];
|
||||||
|
|
||||||
|
const n = Math.min(aa.length, bb.length);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = aa[i]!;
|
||||||
|
const y = bb[i]!;
|
||||||
|
if (x === y) continue;
|
||||||
|
return x < y ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aa.length === bb.length) return 0;
|
||||||
|
return aa.length < bb.length ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
68
client/src/features/textWrap/core/enKeyword.ts
Normal file
68
client/src/features/textWrap/core/enKeyword.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { CoreConfig } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EN 常见标点(默认集合)
|
||||||
|
*
|
||||||
|
* 说明:此集合只用于“关键词命中判定”的两端 strip,不影响 tokenize(仍为极简派)。
|
||||||
|
* 必须全端一致;后续若扩展,也必须通过 configVersion 管理。
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PUNCTUATION_STRIP_SET_EN: string[] = [
|
||||||
|
',',
|
||||||
|
'.',
|
||||||
|
'!',
|
||||||
|
'?',
|
||||||
|
':',
|
||||||
|
';',
|
||||||
|
'"',
|
||||||
|
"'",
|
||||||
|
'…',
|
||||||
|
'—',
|
||||||
|
'–',
|
||||||
|
'(',
|
||||||
|
')',
|
||||||
|
'[',
|
||||||
|
']',
|
||||||
|
'{',
|
||||||
|
'}',
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildStripSet(config?: Pick<CoreConfig, 'punctuationStripSetEN'>): Set<string> {
|
||||||
|
const list = config?.punctuationStripSetEN?.length ? config.punctuationStripSetEN : DEFAULT_PUNCTUATION_STRIP_SET_EN;
|
||||||
|
return new Set(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化一个 token,用于 EN 关键词命中(全词等值匹配)。
|
||||||
|
*
|
||||||
|
* 口径(必须):
|
||||||
|
* - lowercase
|
||||||
|
* - strip 两端常见标点(可重复剥离,例如 `"\"but,\""`)
|
||||||
|
* - 不允许 substring/contains
|
||||||
|
*/
|
||||||
|
export function normalizeENKeyword(tokenText: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): string {
|
||||||
|
const stripSet = buildStripSet(config);
|
||||||
|
|
||||||
|
let s = String(tokenText ?? '').toLowerCase();
|
||||||
|
if (!s) return '';
|
||||||
|
|
||||||
|
// 仅剥离两端的“常见标点”,内部字符不处理
|
||||||
|
let start = 0;
|
||||||
|
let end = s.length;
|
||||||
|
|
||||||
|
while (start < end && stripSet.has(s[start]!)) start++;
|
||||||
|
while (end > start && stripSet.has(s[end - 1]!)) end--;
|
||||||
|
|
||||||
|
// 如果剥离后还剩空白,再做一次 trim(避免 `"but "` 这种输入)
|
||||||
|
return s.slice(start, end).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EN 关键词命中:全词等值匹配(禁止 substring)。
|
||||||
|
*/
|
||||||
|
export function matchENKeyword(tokenText: string, keyword: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): boolean {
|
||||||
|
const a = normalizeENKeyword(tokenText, config);
|
||||||
|
const b = normalizeENKeyword(keyword, config);
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a === b;
|
||||||
|
}
|
||||||
|
|
||||||
8
client/src/features/textWrap/core/index.ts
Normal file
8
client/src/features/textWrap/core/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type { CoreConfig, Lang, NormalizeWhitespaceResult, Token, WhitespacePolicy } from './types';
|
||||||
|
|
||||||
|
export { normalizeWhitespace } from './normalizeWhitespace';
|
||||||
|
export { tokenizeEN } from './tokenizeEN';
|
||||||
|
export { joinTokens } from './joinTokens';
|
||||||
|
export { DEFAULT_PUNCTUATION_STRIP_SET_EN, matchENKeyword, normalizeENKeyword } from './enKeyword';
|
||||||
|
export { compareBreaksLexicographically } from './compare';
|
||||||
|
|
||||||
37
client/src/features/textWrap/core/joinTokens.ts
Normal file
37
client/src/features/textWrap/core/joinTokens.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Token } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 token 区间 `[start..end)` 重组回文本(跨端口径)。
|
||||||
|
*
|
||||||
|
* - 默认:用单空格 join(适用于 whitespacePolicy=NORMALIZE)
|
||||||
|
* - 若传入 rawSeparators:按原始分隔符拼接(适用于 whitespacePolicy=PRESERVE)
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - `start/end` 为半开区间
|
||||||
|
* - 空区间返回空字符串;上层需要用硬约束避免空行
|
||||||
|
*/
|
||||||
|
export function joinTokens(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
|
||||||
|
const s = Math.max(0, start | 0);
|
||||||
|
const e = Math.max(0, end | 0);
|
||||||
|
if (!Array.isArray(tokens) || tokens.length === 0) return '';
|
||||||
|
if (s >= e) return '';
|
||||||
|
|
||||||
|
const slice = tokens.slice(s, e).map((t) => t.text);
|
||||||
|
if (!rawSeparators) {
|
||||||
|
return slice.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawSeparators[i] 表示 tokens[i] 与 tokens[i+1] 之间的分隔符
|
||||||
|
// 这里只实现最小可用:若 separators 缺失则回退到单空格。
|
||||||
|
let out = '';
|
||||||
|
for (let idx = s; idx < e; idx++) {
|
||||||
|
const t = tokens[idx];
|
||||||
|
if (!t) continue;
|
||||||
|
out += t.text;
|
||||||
|
if (idx < e - 1) {
|
||||||
|
out += rawSeparators[idx] ?? ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
28
client/src/features/textWrap/core/normalizeWhitespace.ts
Normal file
28
client/src/features/textWrap/core/normalizeWhitespace.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { NormalizeWhitespaceResult, WhitespacePolicy } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空白归一化(跨端口径)
|
||||||
|
*
|
||||||
|
* - NORMALIZE:折叠连续空白为单空格,并去首尾空白
|
||||||
|
* - PRESERVE:仅去首尾空白(内部空白保持原样)
|
||||||
|
*
|
||||||
|
* 注意:该函数会改变输入文本(至少会 trim),必须作为“算法契约”的一部分固定下来。
|
||||||
|
*/
|
||||||
|
export function normalizeWhitespace(text: string, policy: WhitespacePolicy = 'NORMALIZE'): NormalizeWhitespaceResult {
|
||||||
|
const input = String(text ?? '');
|
||||||
|
|
||||||
|
if (policy === 'PRESERVE') {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
// 只要发生 trim,或内部存在非单空格的分隔(如 \n/\t/多个空格),都算 hadMultiWhitespace
|
||||||
|
const hadMultiWhitespace = trimmed !== input || /[\t\r\n]/.test(trimmed) || / +/.test(trimmed);
|
||||||
|
return { normalizedText: trimmed, hadMultiWhitespace };
|
||||||
|
}
|
||||||
|
|
||||||
|
// NORMALIZE:把任意连续空白折叠为 1 个空格,并去首尾空白
|
||||||
|
// 说明:这里使用 \s+ 覆盖空格/换行/制表等;跨端需要保证采用同等语义的实现。
|
||||||
|
const trimmed = input.trim();
|
||||||
|
const collapsed = trimmed.replace(/\s+/g, ' ');
|
||||||
|
const hadMultiWhitespace = collapsed !== input;
|
||||||
|
return { normalizedText: collapsed, hadMultiWhitespace };
|
||||||
|
}
|
||||||
|
|
||||||
38
client/src/features/textWrap/core/tokenizeEN.ts
Normal file
38
client/src/features/textWrap/core/tokenizeEN.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Token } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EN tokenize(极简派)
|
||||||
|
*
|
||||||
|
* 口径:
|
||||||
|
* - tokens 只包含 WORD,不生成 SPACE token
|
||||||
|
* - 标点视为“词内字符”,不额外拆分(例如 "tired."、"Wait..."、"hello—world" 都是一个 token)
|
||||||
|
* - 断点只允许发生在“词与词之间”(由上层生成 breakpoint 时遵守)
|
||||||
|
*
|
||||||
|
* start/end 索引以 normalizedText 为基准(建议先调用 normalizeWhitespace)。
|
||||||
|
*/
|
||||||
|
export function tokenizeEN(normalizedText: string): Token[] {
|
||||||
|
const s = String(normalizedText ?? '');
|
||||||
|
if (!s) return [];
|
||||||
|
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
|
||||||
|
// 由于 NORMALIZE 模式下空白都折叠为单空格,这里按空格扫描即可。
|
||||||
|
// 为了稳健性,也允许出现意外多空格(会跳过空段)。
|
||||||
|
let i = 0;
|
||||||
|
const n = s.length;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
// 跳过空格(或其他空白字符)
|
||||||
|
while (i < n && /\s/.test(s[i]!)) i++;
|
||||||
|
if (i >= n) break;
|
||||||
|
|
||||||
|
const start = i;
|
||||||
|
while (i < n && !/\s/.test(s[i]!)) i++;
|
||||||
|
const end = i;
|
||||||
|
|
||||||
|
tokens.push({ text: s.slice(start, end), start, end });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
40
client/src/features/textWrap/core/types.ts
Normal file
40
client/src/features/textWrap/core/types.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Text Wrap - core-contract
|
||||||
|
*
|
||||||
|
* 本文件定义“跨端一致的基础口径”所需的最小类型集合。
|
||||||
|
* 注意:这里的索引(start/end)默认以 normalizedText(归一化后的文本)为基准。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Lang = 'TC' | 'EN';
|
||||||
|
|
||||||
|
export type WhitespacePolicy = 'NORMALIZE' | 'PRESERVE';
|
||||||
|
|
||||||
|
export type Token = {
|
||||||
|
/** token 的原始文本(EN:词;TC:字符簇) */
|
||||||
|
text: string;
|
||||||
|
/** token 在 normalizedText 中的起始索引(包含) */
|
||||||
|
start: number;
|
||||||
|
/** token 在 normalizedText 中的结束索引(不包含) */
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NormalizeWhitespaceResult = {
|
||||||
|
/**
|
||||||
|
* 归一化后的文本。
|
||||||
|
* - NORMALIZE:折叠连续空白为单空格,并去首尾空白
|
||||||
|
* - PRESERVE:仅去首尾空白(内部空白保持原样)
|
||||||
|
*/
|
||||||
|
normalizedText: string;
|
||||||
|
/** 是否发生过“空白折叠/trim/非单空格分隔”等(用于后续 meta 打点) */
|
||||||
|
hadMultiWhitespace: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CoreConfig = {
|
||||||
|
whitespacePolicy: WhitespacePolicy;
|
||||||
|
/**
|
||||||
|
* EN 关键词命中:两端可剥离的常见标点集合(必须全端一致)。
|
||||||
|
* 来源建议与默认值参考:`设计说明文档/文档换行算法.md`(v1.2.1)2.3.1A-1
|
||||||
|
*/
|
||||||
|
punctuationStripSetEN: string[];
|
||||||
|
};
|
||||||
|
|
||||||
97
client/src/features/textWrap/golden/__tests__/golden.test.ts
Normal file
97
client/src/features/textWrap/golden/__tests__/golden.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { Token } from '../../core/types';
|
||||||
|
import { normalizeWhitespace, tokenizeEN } from '../../core/index';
|
||||||
|
import { segmentGraphemes } from '../../grapheme/index';
|
||||||
|
import { wrapText } from '../../index';
|
||||||
|
import { GOLDEN_CASES } from '../fixtures';
|
||||||
|
|
||||||
|
function tokenizeTC(normalizedText: string): Token[] {
|
||||||
|
const { clusters } = segmentGraphemes(normalizedText, 'PREFERRED');
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let idx = 0;
|
||||||
|
for (const c of clusters) {
|
||||||
|
const start = idx;
|
||||||
|
idx += c.length;
|
||||||
|
tokens.push({ text: c, start, end: idx });
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wrapTextHarness(args: {
|
||||||
|
text: string;
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
context: 'APP' | 'WIDGET';
|
||||||
|
availableWidth: number;
|
||||||
|
maxLines: number;
|
||||||
|
}): Promise<{ ok: true; lines: string[]; wrappedText: string } | { ok: false; detail: any }> {
|
||||||
|
const out = await wrapText({
|
||||||
|
text: args.text,
|
||||||
|
lang: args.lang,
|
||||||
|
context: args.context,
|
||||||
|
availableWidth: args.availableWidth,
|
||||||
|
maxLines: args.maxLines,
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: async ({ text }) => text.length,
|
||||||
|
contextProfile: `${args.context}|golden`,
|
||||||
|
lineMode: 'AUTO',
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// wrapText 统一返回成功形态;若进入 SYSTEM_DEFAULT 等兜底,meta 会标记
|
||||||
|
return { ok: true, lines: out.lines, wrappedText: out.wrappedText };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap golden-tests', () => {
|
||||||
|
for (const c of GOLDEN_CASES) {
|
||||||
|
it(`golden:${c.id}`, async () => {
|
||||||
|
const res = await wrapTextHarness({
|
||||||
|
text: c.text,
|
||||||
|
lang: c.lang,
|
||||||
|
context: c.context,
|
||||||
|
availableWidth: c.availableWidth,
|
||||||
|
maxLines: c.maxLines,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`golden case failed: ${c.id}\n${JSON.stringify(res.detail, null, 2)}`);
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
expect(res.lines).toEqual(c.expected.lines);
|
||||||
|
expect(res.wrappedText).toBe(c.expected.wrappedText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('property: 确定性(同输入多次调用一致)', async () => {
|
||||||
|
const args = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, availableWidth: 6, maxLines: 2 };
|
||||||
|
const a = await wrapTextHarness(args);
|
||||||
|
const b = await wrapTextHarness(args);
|
||||||
|
const c = await wrapTextHarness(args);
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
expect(b).toEqual(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('property: 近似单调性(availableWidth 变小不会让最大行宽变大)', async () => {
|
||||||
|
const base = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, maxLines: 2 };
|
||||||
|
const wide = await wrapTextHarness({ ...base, availableWidth: 20 });
|
||||||
|
const narrow = await wrapTextHarness({ ...base, availableWidth: 7 });
|
||||||
|
|
||||||
|
if (!wide.ok || !narrow.ok) {
|
||||||
|
throw new Error(`property failed: expected both ok\nwide=${JSON.stringify(wide)}\nnarrow=${JSON.stringify(narrow)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxW = (lines: string[]) => Math.max(...lines.map((s) => s.length));
|
||||||
|
expect(maxW(narrow.lines)).toBeLessThanOrEqual(maxW(wide.lines));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('property: maxLines 不变差(maxLines 增加不应从可解变无解)', async () => {
|
||||||
|
const base = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, availableWidth: 7 };
|
||||||
|
const a = await wrapTextHarness({ ...base, maxLines: 2 });
|
||||||
|
const b = await wrapTextHarness({ ...base, maxLines: 3 });
|
||||||
|
|
||||||
|
expect(a.ok).toBe(true);
|
||||||
|
expect(b.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
56
client/src/features/textWrap/golden/fixtures.ts
Normal file
56
client/src/features/textWrap/golden/fixtures.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export type GoldenCase = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
context: 'APP' | 'WIDGET';
|
||||||
|
availableWidth: number;
|
||||||
|
maxLines: number;
|
||||||
|
expected: { lines: string[]; wrappedText: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Golden fixtures(首版最小可运行集)
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - APP:测量 mock=string.length,因此 availableWidth 也是“字符数单位”
|
||||||
|
* - WIDGET:widthMode=APPROX,因此 availableWidth 是“token 数单位”
|
||||||
|
*/
|
||||||
|
export const GOLDEN_CASES: GoldenCase[] = [
|
||||||
|
{
|
||||||
|
id: 'en_app_simple_2lines',
|
||||||
|
text: 'I am so tired',
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 7,
|
||||||
|
maxLines: 2,
|
||||||
|
expected: { lines: ['I am so', 'tired'], wrappedText: 'I am so\ntired' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'en_app_punct_keyword',
|
||||||
|
text: 'but, still ok',
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 9,
|
||||||
|
maxLines: 2,
|
||||||
|
expected: { lines: ['but,', 'still ok'], wrappedText: 'but,\nstill ok' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tc_app_punct_2lines',
|
||||||
|
text: '我好累,😮💨',
|
||||||
|
lang: 'TC',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 6,
|
||||||
|
maxLines: 2,
|
||||||
|
expected: { lines: ['我好累,', '😮💨'], wrappedText: '我好累,\n😮💨' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tc_widget_approx_2lines',
|
||||||
|
text: '我好累',
|
||||||
|
lang: 'TC',
|
||||||
|
context: 'WIDGET',
|
||||||
|
availableWidth: 2,
|
||||||
|
maxLines: 2,
|
||||||
|
expected: { lines: ['我', '好累'], wrappedText: '我\n好累' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { segmentGraphemes } from '../index';
|
||||||
|
|
||||||
|
const REQUIRED_SINGLE_CLUSTER_CASES: Array<{ name: string; input: string }> = [
|
||||||
|
{ name: 'ZWJ family', input: '👨👩👧👦' },
|
||||||
|
{ name: 'flag', input: '🇸🇬' },
|
||||||
|
{ name: 'skin tone', input: '👍🏽' },
|
||||||
|
{ name: 'ZWJ + variation', input: '😮💨' },
|
||||||
|
{ name: 'combining mark', input: 'e\u0301' }, // é(组合形式)
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('textWrap grapheme-segmentation', () => {
|
||||||
|
it('基础性质:空字符串 -> []', () => {
|
||||||
|
const out = segmentGraphemes('', 'PREFERRED');
|
||||||
|
expect(out.clusters).toEqual([]);
|
||||||
|
expect(out.clusters.join('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('基础性质:可逆性(clusters.join("") === input)', () => {
|
||||||
|
const input = '我好累😮💨';
|
||||||
|
const out = segmentGraphemes(input, 'PREFERRED');
|
||||||
|
expect(out.clusters.join('')).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('基础性质:确定性(同输入同输出)', () => {
|
||||||
|
const input = '👨👩👧👦🇸🇬👍🏽😮💨e\u0301';
|
||||||
|
const a = segmentGraphemes(input, 'PREFERRED');
|
||||||
|
const b = segmentGraphemes(input, 'PREFERRED');
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('必测样例:在 PREFERRED 下必须“不拆”为单个 cluster', () => {
|
||||||
|
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||||
|
const out = segmentGraphemes(c.input, 'PREFERRED');
|
||||||
|
expect(out.clusters.join('')).toBe(c.input);
|
||||||
|
expect(out.clusters.length).toBe(1);
|
||||||
|
expect(out.clusters[0]).toBe(c.input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('必测样例:在强制 FALLBACK 下必须“不拆”为单个 cluster', () => {
|
||||||
|
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||||
|
const out = segmentGraphemes(c.input, 'FALLBACK');
|
||||||
|
expect(out.meta.strategy).toBe('FALLBACK');
|
||||||
|
expect(out.meta.hadFallback).toBe(true);
|
||||||
|
expect(out.clusters.join('')).toBe(c.input);
|
||||||
|
expect(out.clusters.length).toBe(1);
|
||||||
|
expect(out.clusters[0]).toBe(c.input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('跨策略一致性:若 Intl.Segmenter 可用,则 PREFERRED 与 FALLBACK 输出应一致', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const hasIntl = typeof (globalThis as any)?.Intl?.Segmenter === 'function';
|
||||||
|
if (!hasIntl) return;
|
||||||
|
|
||||||
|
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||||
|
const preferred = segmentGraphemes(c.input, 'PREFERRED');
|
||||||
|
const fallback = segmentGraphemes(c.input, 'FALLBACK');
|
||||||
|
expect(preferred.clusters).toEqual(fallback.clusters);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
9
client/src/features/textWrap/grapheme/index.ts
Normal file
9
client/src/features/textWrap/grapheme/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type {
|
||||||
|
GraphemeSegmentationMeta,
|
||||||
|
GraphemeSegmentationMode,
|
||||||
|
GraphemeSegmentationResult,
|
||||||
|
GraphemeSegmentationStrategy,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { segmentGraphemes } from './segmentGraphemes';
|
||||||
|
|
||||||
42
client/src/features/textWrap/grapheme/segmentGraphemes.ts
Normal file
42
client/src/features/textWrap/grapheme/segmentGraphemes.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { GraphemeSegmentationMode, GraphemeSegmentationResult } from './types';
|
||||||
|
import { canUseIntlSegmenter, segmentWithIntlSegmenter } from './strategies/intlSegmenter';
|
||||||
|
import { segmentWithFallback } from './strategies/fallback';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把字符串切分为 grapheme clusters(字符簇)。
|
||||||
|
*
|
||||||
|
* 契约(必须):
|
||||||
|
* - clusters.join('') === text(不允许丢字符/改顺序)
|
||||||
|
* - text=='' 时 clusters==[]
|
||||||
|
* - 任何异常都必须降级到 fallback(保证可用性)
|
||||||
|
*/
|
||||||
|
export function segmentGraphemes(text: string, mode: GraphemeSegmentationMode = 'PREFERRED'): GraphemeSegmentationResult {
|
||||||
|
const input = String(text ?? '');
|
||||||
|
if (input === '') {
|
||||||
|
return { clusters: [], meta: { strategy: 'FALLBACK', hadFallback: mode === 'FALLBACK' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制 fallback
|
||||||
|
if (mode === 'FALLBACK') {
|
||||||
|
const clusters = segmentWithFallback(input);
|
||||||
|
return { clusters, meta: { strategy: 'FALLBACK', hadFallback: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先 Intl.Segmenter
|
||||||
|
if (canUseIntlSegmenter()) {
|
||||||
|
try {
|
||||||
|
const clusters = segmentWithIntlSegmenter(input);
|
||||||
|
// 防御:必须可逆
|
||||||
|
if (clusters.length > 0 && clusters.join('') === input) {
|
||||||
|
return { clusters, meta: { strategy: 'INTL_SEGMENTER', hadFallback: false } };
|
||||||
|
}
|
||||||
|
// 若结果异常(空/丢字符),走 fallback
|
||||||
|
} catch {
|
||||||
|
// ignore -> fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusters = segmentWithFallback(input);
|
||||||
|
return { clusters, meta: { strategy: 'FALLBACK', hadFallback: true } };
|
||||||
|
}
|
||||||
|
|
||||||
81
client/src/features/textWrap/grapheme/strategies/fallback.ts
Normal file
81
client/src/features/textWrap/grapheme/strategies/fallback.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import GraphemeSplitter from 'grapheme-splitter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fallback 策略:使用 grapheme-splitter 分割 grapheme clusters。
|
||||||
|
*
|
||||||
|
* 选择原因:
|
||||||
|
* - 避免手写不完整的 Unicode 规则导致“漏拆/误拆”
|
||||||
|
* - 作为 Intl.Segmenter 不可用时的稳定兜底
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* - grapheme-splitter 对少数较新的 emoji ZWJ 序列支持可能不完整(例如 `😮💨`)。
|
||||||
|
* - 为满足本项目“至少不拆 ZWJ sequence/VS16/肤色修饰符/组合字符”的最低要求,这里做一层确定性的后处理合并。
|
||||||
|
*/
|
||||||
|
export function segmentWithFallback(text: string): string[] {
|
||||||
|
const splitter = new GraphemeSplitter();
|
||||||
|
const raw = splitter.splitGraphemes(text);
|
||||||
|
return mergeFallbackClusters(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZWJ = '\u200D';
|
||||||
|
|
||||||
|
function firstCodePoint(s: string): number | null {
|
||||||
|
if (!s) return null;
|
||||||
|
const cp = s.codePointAt(0);
|
||||||
|
return typeof cp === 'number' ? cp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVariationSelector(cp: number): boolean {
|
||||||
|
// VS15/VS16
|
||||||
|
return cp === 0xfe0e || cp === 0xfe0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSkinToneModifier(cp: number): boolean {
|
||||||
|
return cp >= 0x1f3fb && cp <= 0x1f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCombiningMark(cp: number): boolean {
|
||||||
|
// 常见 combining marks 范围(覆盖 `e\u0301` 等)
|
||||||
|
return (
|
||||||
|
(cp >= 0x0300 && cp <= 0x036f) ||
|
||||||
|
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
||||||
|
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
||||||
|
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
||||||
|
(cp >= 0xfe20 && cp <= 0xfe2f)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对 fallback 输出做“最低可用”合并:
|
||||||
|
* - 若上一个 cluster 以 ZWJ 结尾,则必须与下一个合并(ZWJ sequence)
|
||||||
|
* - 若下一个 cluster 以 VS/肤色修饰符/combining mark 开头,也与上一个合并
|
||||||
|
*
|
||||||
|
* 目标:满足文档列出的“不拆”组合要求,且行为完全确定性。
|
||||||
|
*/
|
||||||
|
function mergeFallbackClusters(raw: string[]): string[] {
|
||||||
|
if (!raw.length) return raw;
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
let buf = raw[0] ?? '';
|
||||||
|
|
||||||
|
for (let i = 1; i < raw.length; i++) {
|
||||||
|
const next = raw[i] ?? '';
|
||||||
|
const nextCp = firstCodePoint(next);
|
||||||
|
|
||||||
|
const shouldMerge =
|
||||||
|
buf.endsWith(ZWJ) ||
|
||||||
|
(nextCp !== null && (isVariationSelector(nextCp) || isSkinToneModifier(nextCp) || isCombiningMark(nextCp)));
|
||||||
|
|
||||||
|
if (shouldMerge) {
|
||||||
|
buf += next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(buf);
|
||||||
|
buf = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(buf);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Intl.Segmenter 策略(优先)
|
||||||
|
*
|
||||||
|
* 注意:不同 JS 引擎/版本对 Intl.Segmenter 的支持可能不同,调用方必须捕获异常并降级。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function canUseIntlSegmenter(): boolean {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const Seg = (globalThis as any)?.Intl?.Segmenter;
|
||||||
|
return typeof Seg === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function segmentWithIntlSegmenter(text: string): string[] {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const Segmenter = (globalThis as any).Intl.Segmenter as new (locales?: string | string[], options?: any) => any;
|
||||||
|
|
||||||
|
// 用 zh-Hant 仅用于选择合适的 locale;grapheme 分割应与语言本身关系不大,但保持固定输入更易对齐跨端。
|
||||||
|
const seg = new Segmenter('zh-Hant', { granularity: 'grapheme' });
|
||||||
|
const it = seg.segment(text);
|
||||||
|
|
||||||
|
const clusters: string[] = [];
|
||||||
|
for (const part of it) {
|
||||||
|
// part: { segment: string, index: number, input: string, isWordLike?: boolean }
|
||||||
|
clusters.push(part.segment);
|
||||||
|
}
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
21
client/src/features/textWrap/grapheme/types.ts
Normal file
21
client/src/features/textWrap/grapheme/types.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Text Wrap - grapheme-segmentation
|
||||||
|
*
|
||||||
|
* 本模块只负责把字符串切成“字符簇(grapheme clusters)”数组。
|
||||||
|
* 后续 TC 换行断点只能发生在 clusters 边界。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GraphemeSegmentationMode = 'PREFERRED' | 'FALLBACK';
|
||||||
|
|
||||||
|
export type GraphemeSegmentationStrategy = 'INTL_SEGMENTER' | 'FALLBACK';
|
||||||
|
|
||||||
|
export type GraphemeSegmentationMeta = {
|
||||||
|
strategy: GraphemeSegmentationStrategy;
|
||||||
|
hadFallback: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GraphemeSegmentationResult = {
|
||||||
|
clusters: string[];
|
||||||
|
meta: GraphemeSegmentationMeta;
|
||||||
|
};
|
||||||
|
|
||||||
4
client/src/features/textWrap/index.ts
Normal file
4
client/src/features/textWrap/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type { WrapTextConstraints, WrapTextInput, WrapTextMeta, WrapTextOutput } from './types';
|
||||||
|
|
||||||
|
export { wrapText } from './wrapText';
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { MeasureWidthImpl } from '../types';
|
||||||
|
import { MissingFontSpecError } from '../errors';
|
||||||
|
import { buildFontSpecKey } from '../fontSpecKey';
|
||||||
|
import { measureSliceWidthCached } from '../measureSliceWidthCached';
|
||||||
|
import { measureWidthCached } from '../measureWidthCached';
|
||||||
|
|
||||||
|
describe('textWrap width-measurement', () => {
|
||||||
|
it('buildFontSpecKey: 缺字段必须报错(简体中文)', () => {
|
||||||
|
expect(() => buildFontSpecKey({ fontFamily: 'PingFangSC-Regular', fontWeight: '400' } as any)).toThrow(
|
||||||
|
MissingFontSpecError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measureWidthCached: 未提供测量能力 -> approx + WIDTH_UNKNOWN', async () => {
|
||||||
|
const res = await measureWidthCached({
|
||||||
|
text: 'hello',
|
||||||
|
context: 'WIDGET',
|
||||||
|
contextProfile: 'WIDGET',
|
||||||
|
fontSpec: null,
|
||||||
|
measureWidthImpl: undefined,
|
||||||
|
});
|
||||||
|
expect(res.width).toBeNull();
|
||||||
|
expect(res.meta).toEqual({ isApprox: true, reason: 'WIDTH_UNKNOWN' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measureWidthCached: 同 key 命中缓存(不会重复调用底层测量)', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
|
||||||
|
const a = await measureWidthCached({
|
||||||
|
text: 'abc',
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
});
|
||||||
|
const b = await measureWidthCached({
|
||||||
|
text: 'abc',
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(a.width).toBe(3);
|
||||||
|
expect(b.width).toBe(3);
|
||||||
|
expect(impl).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measureWidthCached: 测量抛错 -> approx + MEASURE_FAILED', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await measureWidthCached({
|
||||||
|
text: 'abc',
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios|throw',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.width).toBeNull();
|
||||||
|
expect(res.meta).toEqual({ isApprox: true, reason: 'MEASURE_FAILED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measureSliceWidthCached: 同 sliceKey 命中缓存', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
|
||||||
|
const tokens = [
|
||||||
|
{ text: 'I', start: 0, end: 1 },
|
||||||
|
{ text: 'am', start: 2, end: 4 },
|
||||||
|
{ text: 'tired', start: 5, end: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const a = await measureSliceWidthCached({
|
||||||
|
tokens: tokens as any,
|
||||||
|
start: 0,
|
||||||
|
end: 2,
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
});
|
||||||
|
const b = await measureSliceWidthCached({
|
||||||
|
tokens: tokens as any,
|
||||||
|
start: 0,
|
||||||
|
end: 2,
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(a.width).toBe('I am'.length);
|
||||||
|
expect(b.width).toBe('I am'.length);
|
||||||
|
expect(impl).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
62
client/src/features/textWrap/measure/cache.ts
Normal file
62
client/src/features/textWrap/measure/cache.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Text Wrap - width-measurement 缓存
|
||||||
|
*
|
||||||
|
* 要求:
|
||||||
|
* - 模块级常驻缓存(跨调用复用)
|
||||||
|
* - 有容量上限(避免内存无限增长)
|
||||||
|
* - Key 必须确定性(由上层拼接传入)
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - 这里实现一个最小 LRU:Map 维护插入顺序;get 时“刷新”为最新。
|
||||||
|
* - 缓存 value 允许是 Promise,以便并发请求去重(同 key 只测一次)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class LruCache<V> {
|
||||||
|
private readonly maxSize: number;
|
||||||
|
private readonly map: Map<string, V>;
|
||||||
|
|
||||||
|
constructor(maxSize: number) {
|
||||||
|
if (!Number.isFinite(maxSize) || maxSize <= 0) {
|
||||||
|
throw new Error('LRU 缓存 maxSize 必须为正数。');
|
||||||
|
}
|
||||||
|
this.maxSize = Math.floor(maxSize);
|
||||||
|
this.map = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): V | undefined {
|
||||||
|
const v = this.map.get(key);
|
||||||
|
if (v === undefined) return undefined;
|
||||||
|
// 刷新为最新
|
||||||
|
this.map.delete(key);
|
||||||
|
this.map.set(key, v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, value: V): void {
|
||||||
|
if (this.map.has(key)) {
|
||||||
|
this.map.delete(key);
|
||||||
|
}
|
||||||
|
this.map.set(key, value);
|
||||||
|
|
||||||
|
if (this.map.size > this.maxSize) {
|
||||||
|
// 淘汰最旧的
|
||||||
|
const oldestKey = this.map.keys().next().value as string | undefined;
|
||||||
|
if (oldestKey !== undefined) {
|
||||||
|
this.map.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void {
|
||||||
|
this.map.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.map.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
return this.map.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
19
client/src/features/textWrap/measure/errors.ts
Normal file
19
client/src/features/textWrap/measure/errors.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Text Wrap - width-measurement 错误定义
|
||||||
|
*
|
||||||
|
* 约束:fontSpec 缺字段必须直接报错(简体中文),禁止隐式默认值。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class MissingFontSpecError extends Error {
|
||||||
|
readonly name = 'MissingFontSpecError';
|
||||||
|
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMissingFontSpecMessage(missingFields: string[]): string {
|
||||||
|
const fields = missingFields.join(', ');
|
||||||
|
return `fontSpec 缺少必填字段:${fields}。请在调用 wrapText() 时补齐 fontSpec(fontFamily/fontWeight/fontSize),禁止在测量层使用隐式默认值。`;
|
||||||
|
}
|
||||||
|
|
||||||
32
client/src/features/textWrap/measure/fontSpecKey.ts
Normal file
32
client/src/features/textWrap/measure/fontSpecKey.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FontSpec } from './types';
|
||||||
|
import { MissingFontSpecError, buildMissingFontSpecMessage } from './errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 fontSpec 必填字段。
|
||||||
|
* 约束:缺失字段必须报错(简体中文),避免跨端漂移。
|
||||||
|
*/
|
||||||
|
export function assertFontSpecComplete(fontSpec: Partial<FontSpec> | undefined | null): asserts fontSpec is FontSpec {
|
||||||
|
if (!fontSpec) {
|
||||||
|
throw new MissingFontSpecError(buildMissingFontSpecMessage(['fontFamily', 'fontWeight', 'fontSize']));
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!fontSpec.fontFamily) missing.push('fontFamily');
|
||||||
|
if (!fontSpec.fontWeight) missing.push('fontWeight');
|
||||||
|
if (fontSpec.fontSize === undefined || fontSpec.fontSize === null) missing.push('fontSize');
|
||||||
|
|
||||||
|
if (missing.length) throw new MissingFontSpecError(buildMissingFontSpecMessage(missing));
|
||||||
|
if (!Number.isFinite(fontSpec.fontSize)) {
|
||||||
|
throw new MissingFontSpecError('fontSpec.fontSize 必须是有限数值(number)。请检查调用 wrapText() 时传入的 fontSize。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 fontSpecKey(确定性)。
|
||||||
|
* 规则:`fontFamily|fontWeight|fontSize`(顺序固定)
|
||||||
|
*/
|
||||||
|
export function buildFontSpecKey(fontSpec: Partial<FontSpec> | undefined | null): string {
|
||||||
|
assertFontSpecComplete(fontSpec);
|
||||||
|
return `${fontSpec.fontFamily}|${fontSpec.fontWeight}|${String(fontSpec.fontSize)}`;
|
||||||
|
}
|
||||||
|
|
||||||
16
client/src/features/textWrap/measure/index.ts
Normal file
16
client/src/features/textWrap/measure/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export type {
|
||||||
|
ContextProfile,
|
||||||
|
FontSpec,
|
||||||
|
MeasureMeta,
|
||||||
|
MeasureReason,
|
||||||
|
MeasureResult,
|
||||||
|
MeasureWidthImpl,
|
||||||
|
TextWrapContext,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { MissingFontSpecError } from './errors';
|
||||||
|
export { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||||
|
export { defaultMeasureWidthImpl } from './measureWidthImpl';
|
||||||
|
export { measureWidthCached } from './measureWidthCached';
|
||||||
|
export { measureSliceWidthCached } from './measureSliceWidthCached';
|
||||||
|
|
||||||
105
client/src/features/textWrap/measure/measureSliceWidthCached.ts
Normal file
105
client/src/features/textWrap/measure/measureSliceWidthCached.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { ContextProfile, FontSpec, MeasureResult, MeasureWidthImpl, TextWrapContext } from './types';
|
||||||
|
import type { Token } from '../core/types';
|
||||||
|
import { LruCache } from './cache';
|
||||||
|
import { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
import { measureWidthCached } from './measureWidthCached';
|
||||||
|
|
||||||
|
// 切片缓存:同一 (start,end,fontSpecKey,contextProfile) 的宽度只测一次
|
||||||
|
const sliceWidthCache = new LruCache<Promise<number>>(4000);
|
||||||
|
|
||||||
|
function fnv1a32(input: string): string {
|
||||||
|
// 32-bit FNV-1a(确定性、实现简单;用于缓存 key 避免过长)
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash ^= input.charCodeAt(i);
|
||||||
|
// hash *= 16777619(用位运算模拟 32-bit 溢出)
|
||||||
|
hash = (hash + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
|
||||||
|
}
|
||||||
|
return hash.toString(16).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSliceKey(args: {
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpecKey: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
sliceTextHash: string;
|
||||||
|
}): string {
|
||||||
|
// 注意:必须包含 sliceText 的信息,否则不同文本的相同 (start,end) 会发生跨文本串缓存
|
||||||
|
return `${args.contextProfile}|${args.fontSpecKey}|${args.start}|${args.end}|${args.sliceTextHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测量 token 切片 `[start..end)` 的宽度(带缓存)。
|
||||||
|
*
|
||||||
|
* - 切片文本通过 `joinTokens()` 生成(保证与 core-contract 的重组口径一致)
|
||||||
|
* - 内部复用 `measureWidthCached` 的降级语义
|
||||||
|
*/
|
||||||
|
export async function measureSliceWidthCached(args: {
|
||||||
|
tokens: Token[];
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
context: TextWrapContext;
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec?: Partial<FontSpec> | null;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
widgetEnableMeasure?: boolean;
|
||||||
|
rawSeparators?: string[];
|
||||||
|
}): Promise<MeasureResult> {
|
||||||
|
const enabled =
|
||||||
|
typeof args.measureWidthImpl === 'function' && (args.context === 'APP' || args.widgetEnableMeasure === true);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
// 不启用测量时直接走 width=null(保持与 measureWidthCached 一致)
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'WIDTH_UNKNOWN' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFontSpecComplete(args.fontSpec);
|
||||||
|
const fontSpecKey = buildFontSpecKey(args.fontSpec);
|
||||||
|
const sliceText = joinTokens(args.tokens, args.start, args.end, args.rawSeparators);
|
||||||
|
const key = buildSliceKey({
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpecKey,
|
||||||
|
start: args.start,
|
||||||
|
end: args.end,
|
||||||
|
sliceTextHash: fnv1a32(sliceText),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = sliceWidthCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const w = await cached;
|
||||||
|
return { width: w, meta: { isApprox: false } };
|
||||||
|
} catch {
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const res = await measureWidthCached({
|
||||||
|
text: sliceText,
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec: args.fontSpec,
|
||||||
|
measureWidthImpl: args.measureWidthImpl,
|
||||||
|
widgetEnableMeasure: args.widgetEnableMeasure,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.width === null) {
|
||||||
|
throw new Error('切片测量失败或不可用。');
|
||||||
|
}
|
||||||
|
return res.width;
|
||||||
|
})();
|
||||||
|
|
||||||
|
sliceWidthCache.set(key, promise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const w = await promise;
|
||||||
|
return { width: w, meta: { isApprox: false } };
|
||||||
|
} catch {
|
||||||
|
sliceWidthCache.delete(key);
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
110
client/src/features/textWrap/measure/measureWidthCached.ts
Normal file
110
client/src/features/textWrap/measure/measureWidthCached.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { ContextProfile, FontSpec, MeasureResult, MeasureWidthImpl, TextWrapContext } from './types';
|
||||||
|
import { LruCache } from './cache';
|
||||||
|
import { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||||
|
|
||||||
|
// 模块级常驻缓存(两级)
|
||||||
|
const textWidthCache = new LruCache<Promise<number>>(2000);
|
||||||
|
const loggedMeasureFailures = new Set<string>();
|
||||||
|
|
||||||
|
function buildTextKey(args: { contextProfile: ContextProfile; fontSpecKey: string; text: string }): string {
|
||||||
|
// key 拼接规则必须确定性:<contextProfile>|<fontSpecKey>|<text>
|
||||||
|
return `${args.contextProfile}|${args.fontSpecKey}|${args.text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidWidth(v: unknown): v is number {
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测量文本宽度(带缓存)。
|
||||||
|
*
|
||||||
|
* 约束:
|
||||||
|
* - 若启用测量(提供 measureWidthImpl),fontSpec 必须完整;缺字段直接报错(简体中文)
|
||||||
|
* - 若不启用测量(measureWidthImpl 缺失或 context=WIDGET 且明确不启用),进入 approx mode:width=null
|
||||||
|
*/
|
||||||
|
export async function measureWidthCached(args: {
|
||||||
|
text: string;
|
||||||
|
context: TextWrapContext;
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec?: Partial<FontSpec> | null;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
/** WIDGET 场景是否启用测量;默认 false(未启用即 WIDTH_UNKNOWN) */
|
||||||
|
widgetEnableMeasure?: boolean;
|
||||||
|
}): Promise<MeasureResult> {
|
||||||
|
const text = String(args.text ?? '');
|
||||||
|
|
||||||
|
const enabled =
|
||||||
|
typeof args.measureWidthImpl === 'function' && (args.context === 'APP' || args.widgetEnableMeasure === true);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'WIDTH_UNKNOWN' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用测量时:fontSpec 缺字段必须报错
|
||||||
|
const fontSpec = args.fontSpec;
|
||||||
|
assertFontSpecComplete(fontSpec);
|
||||||
|
const fontSpecKey = buildFontSpecKey(fontSpec);
|
||||||
|
const key = buildTextKey({ contextProfile: args.contextProfile, fontSpecKey, text });
|
||||||
|
|
||||||
|
const cached = textWidthCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const w = await cached;
|
||||||
|
return isValidWidth(w) ? { width: w, meta: { isApprox: false } } : { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
} catch {
|
||||||
|
if (__DEV__ && !loggedMeasureFailures.has(key)) {
|
||||||
|
loggedMeasureFailures.add(key);
|
||||||
|
console.log('[TextWrap][Measure] measureWidthCached: 命中缓存但 Promise 失败(MEASURE_FAILED)', {
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec: args.fontSpec,
|
||||||
|
textPreview: text.slice(0, 80),
|
||||||
|
textLength: text.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const w = await args.measureWidthImpl!({
|
||||||
|
text,
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec,
|
||||||
|
});
|
||||||
|
if (!isValidWidth(w)) {
|
||||||
|
throw new Error('测量结果非法(必须为有限且非负的 number)。');
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
} catch (error) {
|
||||||
|
if (__DEV__ && !loggedMeasureFailures.has(key)) {
|
||||||
|
loggedMeasureFailures.add(key);
|
||||||
|
console.log('[TextWrap][Measure] measureWidthImpl 抛错(MEASURE_FAILED)', {
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec,
|
||||||
|
textPreview: text.slice(0, 80),
|
||||||
|
textLength: text.length,
|
||||||
|
errorName: (error as any)?.name,
|
||||||
|
errorMessage: String((error as any)?.message ?? error),
|
||||||
|
errorStack: (error as any)?.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
textWidthCache.set(key, promise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const w = await promise;
|
||||||
|
return { width: w, meta: { isApprox: false } };
|
||||||
|
} catch {
|
||||||
|
// 若失败,删除缓存,避免缓存住失败结果
|
||||||
|
textWidthCache.delete(key);
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
65
client/src/features/textWrap/measure/measureWidthImpl.ts
Normal file
65
client/src/features/textWrap/measure/measureWidthImpl.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { FontSpec, MeasureWidthImpl } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认测量实现(成熟方案):react-native-text-size
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* - 该库的 `measure` 是 Promise 异步接口,本模块以 async 方式对齐。
|
||||||
|
* - 为避免在测试/Node 环境直接 require `react-native` 造成崩溃,这里使用“延迟加载 + 捕获异常”。
|
||||||
|
*/
|
||||||
|
|
||||||
|
type TextSizeMeasureParams = {
|
||||||
|
text: string;
|
||||||
|
width?: number;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
fontWeight?: string;
|
||||||
|
allowFontScaling?: boolean;
|
||||||
|
usePreciseWidth?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextSizeMeasureResult = { width: number };
|
||||||
|
|
||||||
|
async function loadReactNativeTextSize(): Promise<{
|
||||||
|
measure: (params: TextSizeMeasureParams) => Promise<TextSizeMeasureResult>;
|
||||||
|
}> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const mod: any = await import('react-native-text-size');
|
||||||
|
return mod?.default ?? mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTextSizeFontSpecs(fontSpec: FontSpec): Pick<TextSizeMeasureParams, 'fontFamily' | 'fontSize' | 'fontWeight'> {
|
||||||
|
return {
|
||||||
|
fontFamily: fontSpec.fontFamily,
|
||||||
|
fontSize: fontSpec.fontSize,
|
||||||
|
fontWeight: fontSpec.fontWeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认测量函数(可替换/可注入)。
|
||||||
|
* - width 约束设为极大值,避免自动换行影响“单行宽度”测量
|
||||||
|
* - usePreciseWidth=true,取更精确的宽度(开销更大,但对本算法更稳定)
|
||||||
|
*/
|
||||||
|
export const defaultMeasureWidthImpl: MeasureWidthImpl = async ({ text, fontSpec }) => {
|
||||||
|
const TextSize = await loadReactNativeTextSize();
|
||||||
|
if (!TextSize || typeof TextSize.measure !== 'function') {
|
||||||
|
// 典型原因:在 Expo Go 中运行,或没有使用包含该原生模块的 Development Build。
|
||||||
|
// 这里抛出更明确的错误,方便上层捕获并在 meta.reason=MEASURE_FAILED 时看到根因。
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
'react-native-text-size 原生模块不可用:TextSize.measure 不是函数。',
|
||||||
|
'请确认你不是在 Expo Go 里运行;需要使用包含该原生模块的 Development Build(expo-dev-client / expo run:ios / EAS dev build)。',
|
||||||
|
].join(' '),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const res = await TextSize.measure({
|
||||||
|
text,
|
||||||
|
width: 1_000_000_000,
|
||||||
|
usePreciseWidth: true,
|
||||||
|
allowFontScaling: true,
|
||||||
|
...toTextSizeFontSpecs(fontSpec),
|
||||||
|
});
|
||||||
|
return res.width;
|
||||||
|
};
|
||||||
|
|
||||||
48
client/src/features/textWrap/measure/types.ts
Normal file
48
client/src/features/textWrap/measure/types.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Text Wrap - width-measurement
|
||||||
|
*
|
||||||
|
* 本模块负责:
|
||||||
|
* - 宽度测量(可注入实现)
|
||||||
|
* - 两级缓存(文本/切片)
|
||||||
|
* - 测量失败时的确定性降级(approx mode)
|
||||||
|
*
|
||||||
|
* 注意:真实测量(如 react-native-text-size)通常是异步 Promise,本模块统一使用 async 形式对齐实际能力。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TextWrapContext = 'APP' | 'WIDGET';
|
||||||
|
|
||||||
|
export type FontSpec = {
|
||||||
|
fontSize: number;
|
||||||
|
fontFamily: string;
|
||||||
|
fontWeight: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于区分不同测量语境(缓存隔离)。
|
||||||
|
* 口径建议:`APP|<platform>|<scale?>` 或 `WIDGET|<widgetSize?>`
|
||||||
|
*/
|
||||||
|
export type ContextProfile = string;
|
||||||
|
|
||||||
|
export type MeasureReason = 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
|
||||||
|
|
||||||
|
export type MeasureMeta = {
|
||||||
|
isApprox: boolean;
|
||||||
|
reason?: MeasureReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MeasureResult = {
|
||||||
|
width: number | null;
|
||||||
|
meta: MeasureMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可注入的测量实现(成熟方法推荐用原生/离屏测量库)。
|
||||||
|
* 约定:返回值必须是“有限且非负”的 number,否则视为测量失败。
|
||||||
|
*/
|
||||||
|
export type MeasureWidthImpl = (args: {
|
||||||
|
text: string;
|
||||||
|
context: TextWrapContext;
|
||||||
|
fontSpec: FontSpec;
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
}) => Promise<number>;
|
||||||
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { MeasureWidthImpl } from '../../measure/types';
|
||||||
|
import { applyOverflowFallback } from '../fallback';
|
||||||
|
|
||||||
|
function t(text: string, start: number) {
|
||||||
|
return { text, start, end: start + text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap overflow-fallback', () => {
|
||||||
|
it('SYSTEM_DEFAULT:仍返回单行,但 meta 标记 SYSTEM_DEFAULT', async () => {
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('I', 0), t('am', 2), t('tired', 5)],
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 2,
|
||||||
|
overflowMode: 'SYSTEM_DEFAULT',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'NO_CANDIDATE',
|
||||||
|
partialLayout: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.lines.length).toBe(1);
|
||||||
|
expect(res.meta.fallback_type).toBe('SYSTEM_DEFAULT');
|
||||||
|
expect(res.meta.overflow_type).toBe('NONE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CLIP:截断到 maxLines', async () => {
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('a', 0), t('b', 2), t('c', 4)],
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 2,
|
||||||
|
overflowMode: 'CLIP',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'TOO_LONG',
|
||||||
|
partialLayout: {
|
||||||
|
breaks: [1, 2],
|
||||||
|
lines: [
|
||||||
|
{ start: 0, end: 1, text: 'a' },
|
||||||
|
{ start: 1, end: 2, text: 'b' },
|
||||||
|
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||||
|
{ start: 2, end: 2, text: 'c' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.lines).toEqual(['a', 'b']);
|
||||||
|
expect(res.meta.overflow_type).toBe('CLIP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ELLIPSIS:清理末尾空白与 TC 末尾标点(不输出 \" …\" / \",…\")', async () => {
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('我', 0), t('好', 1), t('累', 2), t(',', 3)],
|
||||||
|
lang: 'TC',
|
||||||
|
context: 'WIDGET',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 1,
|
||||||
|
overflowMode: 'ELLIPSIS',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'NO_CANDIDATE',
|
||||||
|
partialLayout: {
|
||||||
|
breaks: [],
|
||||||
|
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||||
|
lines: [{ start: 0, end: 3, text: '我好累, ' }],
|
||||||
|
},
|
||||||
|
tcPunctuations: [','],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.lines[0]).toBe('我好累…');
|
||||||
|
expect(res.meta.overflow_type).toBe('ELLIPSIS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ELLIPSIS:可测量时,超宽则按 token 回退直到不超宽', async () => {
|
||||||
|
// mock:含省略号时宽度=100,否则宽度=文本长度
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => (text.includes('…') ? 100 : text.length));
|
||||||
|
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 1,
|
||||||
|
overflowMode: 'ELLIPSIS',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'NO_CANDIDATE',
|
||||||
|
partialLayout: {
|
||||||
|
breaks: [],
|
||||||
|
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||||
|
lines: [{ start: 0, end: 3, text: 'I am so' }],
|
||||||
|
},
|
||||||
|
measure: {
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios|ellipsis',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 因为任何带 ellipsis 的测量都超宽,这里会一路回退到空串 + ellipsis
|
||||||
|
expect(res.lines[0]).toBe('…');
|
||||||
|
expect(res.meta.overflow_type).toBe('ELLIPSIS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
108
client/src/features/textWrap/overflow/ellipsis.ts
Normal file
108
client/src/features/textWrap/overflow/ellipsis.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
import { measureWidthCached } from '../measure/measureWidthCached';
|
||||||
|
|
||||||
|
import type { ApplyOverflowFallbackInput } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_TC_PUNCTS = [',', '。', '!', '?', ';', ':', '、'];
|
||||||
|
|
||||||
|
export function cleanLineBeforeEllipsis(line: string, lang: 'TC' | 'EN', tcPunctuations?: string[]): string {
|
||||||
|
const s = String(line ?? '');
|
||||||
|
const trimmed = s.replace(/\s+$/g, '');
|
||||||
|
if (trimmed === '') return '';
|
||||||
|
|
||||||
|
if (lang === 'TC') {
|
||||||
|
const puncts = new Set(tcPunctuations && tcPunctuations.length > 0 ? tcPunctuations : DEFAULT_TC_PUNCTS);
|
||||||
|
const last = trimmed.slice(-1);
|
||||||
|
if (puncts.has(last)) return trimmed.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureIfPossible(args: {
|
||||||
|
text: string;
|
||||||
|
input: ApplyOverflowFallbackInput;
|
||||||
|
}): Promise<number | null> {
|
||||||
|
const { input, text } = args;
|
||||||
|
if (!input.measure) return null;
|
||||||
|
|
||||||
|
const enabled =
|
||||||
|
typeof input.measure.measureWidthImpl === 'function' &&
|
||||||
|
(input.measure.context === 'APP' || input.measure.widgetEnableMeasure === true);
|
||||||
|
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
const res = await measureWidthCached({
|
||||||
|
text,
|
||||||
|
context: input.measure.context,
|
||||||
|
contextProfile: input.measure.contextProfile,
|
||||||
|
fontSpec: input.measure.fontSpec,
|
||||||
|
measureWidthImpl: input.measure.measureWidthImpl,
|
||||||
|
widgetEnableMeasure: input.measure.widgetEnableMeasure,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给最后一行加省略号,并在可测量时保证不超宽:
|
||||||
|
* - EN 回退单位:整词(token)
|
||||||
|
* - TC 回退单位:grapheme(token)
|
||||||
|
*/
|
||||||
|
export async function applyEllipsisToLastLine(args: {
|
||||||
|
input: ApplyOverflowFallbackInput;
|
||||||
|
baseLines: Array<{ start: number; end: number; text: string }>;
|
||||||
|
}): Promise<{ lines: string[] }> {
|
||||||
|
const { input } = args;
|
||||||
|
const maxLines = Math.max(1, input.maxLines | 0);
|
||||||
|
const lines = args.baseLines.slice(0, maxLines);
|
||||||
|
if (lines.length === 0) return { lines: [] };
|
||||||
|
|
||||||
|
const lastIdx = lines.length - 1;
|
||||||
|
const last = lines[lastIdx]!;
|
||||||
|
|
||||||
|
// 先按规则清理,再拼接 ellipsisToken
|
||||||
|
const cleaned = cleanLineBeforeEllipsis(last.text, input.lang, input.tcPunctuations);
|
||||||
|
let candidateText = `${cleaned}${input.ellipsisToken}`;
|
||||||
|
|
||||||
|
// 若无测量能力:直接输出(确定性)
|
||||||
|
const measured = await measureIfPossible({ text: candidateText, input });
|
||||||
|
if (measured === null) {
|
||||||
|
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||||
|
return { lines: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有测量能力:若超宽则按 token 回退
|
||||||
|
if (measured <= input.availableWidth) {
|
||||||
|
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||||
|
return { lines: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:逐步减少最后一行 token 数再加省略号
|
||||||
|
let end = last.end;
|
||||||
|
const start = last.start;
|
||||||
|
let foundFit = false;
|
||||||
|
while (end > start) {
|
||||||
|
end -= 1;
|
||||||
|
const base = joinTokens(input.tokens, start, end);
|
||||||
|
const cleaned2 = cleanLineBeforeEllipsis(base, input.lang, input.tcPunctuations);
|
||||||
|
const nextCandidate = `${cleaned2}${input.ellipsisToken}`;
|
||||||
|
const w = await measureIfPossible({ text: nextCandidate, input });
|
||||||
|
if (w !== null && w <= input.availableWidth) {
|
||||||
|
candidateText = nextCandidate;
|
||||||
|
foundFit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若连“仅省略号”都无法满足宽度(极窄场景),也必须给出确定性输出:直接输出 ellipsisToken
|
||||||
|
// 注意:这可能仍然超宽,但已是最小可表达形式;上层可通过 meta 做治理。
|
||||||
|
if (!foundFit) {
|
||||||
|
candidateText = input.ellipsisToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||||
|
return { lines: out };
|
||||||
|
}
|
||||||
|
|
||||||
86
client/src/features/textWrap/overflow/fallback.ts
Normal file
86
client/src/features/textWrap/overflow/fallback.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
|
||||||
|
import { applyEllipsisToLastLine } from './ellipsis';
|
||||||
|
import type { ApplyOverflowFallbackInput, ApplyOverflowFallbackResult, PartialLayoutInput, PartialLayoutLine } from './types';
|
||||||
|
|
||||||
|
function nTokens(tokens: Token[]): number {
|
||||||
|
return Array.isArray(tokens) ? tokens.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coversToEnd(partial: PartialLayoutInput | null | undefined, n: number): boolean {
|
||||||
|
if (!partial || !Array.isArray(partial.lines) || partial.lines.length === 0) return false;
|
||||||
|
const last = partial.lines[partial.lines.length - 1];
|
||||||
|
return (last?.end ?? -1) === n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinesFromPartial(args: {
|
||||||
|
tokens: Token[];
|
||||||
|
partial?: PartialLayoutInput | null;
|
||||||
|
maxLines: number;
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
}): Array<{ start: number; end: number; text: string }> {
|
||||||
|
const N = nTokens(args.tokens);
|
||||||
|
const maxLines = Math.max(1, args.maxLines | 0);
|
||||||
|
const rawSeparators = args.lang === 'TC' ? Array.from({ length: Math.max(0, N) }, () => '') : undefined;
|
||||||
|
|
||||||
|
if (args.partial && Array.isArray(args.partial.lines) && args.partial.lines.length > 0) {
|
||||||
|
const out: Array<{ start: number; end: number; text: string }> = [];
|
||||||
|
for (const line of args.partial.lines.slice(0, maxLines)) {
|
||||||
|
const start = Math.max(0, line.start | 0);
|
||||||
|
const end = Math.min(N, Math.max(start, line.end | 0));
|
||||||
|
const text = line.text ?? joinTokens(args.tokens, start, end, rawSeparators);
|
||||||
|
out.push({ start, end, text });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若没有 partial:把全文当单行 best-effort
|
||||||
|
return [{ start: 0, end: N, text: joinTokens(args.tokens, 0, N, rawSeparators) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWrapped(lines: string[]): { lines: string[]; wrappedText: string } {
|
||||||
|
const outLines = lines.filter((s) => s !== undefined) as string[];
|
||||||
|
return { lines: outLines, wrappedText: outLines.join('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSingleLine(tokens: Token[]): { lines: string[]; wrappedText: string } {
|
||||||
|
const N = nTokens(tokens);
|
||||||
|
// 注意:这里不知道语言口径,因此 SYSTEM_DEFAULT 的单行文本在上层保证传入“原文”更稳妥。
|
||||||
|
// 为保持最小可用,这里仍使用默认 joinTokens(EN=空格 join,TC=grapheme 之间可能会有空格)。
|
||||||
|
const text = joinTokens(tokens, 0, N);
|
||||||
|
return { lines: [text], wrappedText: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyOverflowFallback(input: ApplyOverflowFallbackInput): Promise<ApplyOverflowFallbackResult> {
|
||||||
|
const tokens = input.tokens ?? [];
|
||||||
|
const N = nTokens(tokens);
|
||||||
|
const maxLines = Math.max(1, input.maxLines | 0);
|
||||||
|
|
||||||
|
const isOverflow = !coversToEnd(input.partialLayout, N);
|
||||||
|
|
||||||
|
// 若其实没有 overflow:直接返回(NONE)
|
||||||
|
if (!isOverflow) {
|
||||||
|
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||||
|
const { lines, wrappedText } = toWrapped(base.map((l) => l.text));
|
||||||
|
return { lines, wrappedText, meta: { fallback_type: 'NONE', overflow_type: 'NONE', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.overflowMode === 'SYSTEM_DEFAULT') {
|
||||||
|
const { lines, wrappedText } = toSingleLine(tokens);
|
||||||
|
return { lines, wrappedText, meta: { fallback_type: 'SYSTEM_DEFAULT', overflow_type: 'NONE', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.overflowMode === 'CLIP') {
|
||||||
|
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||||
|
const { lines, wrappedText } = toWrapped(base.slice(0, maxLines).map((l) => l.text));
|
||||||
|
return { lines, wrappedText, meta: { fallback_type: 'NONE', overflow_type: 'CLIP', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ELLIPSIS
|
||||||
|
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||||
|
const { lines } = await applyEllipsisToLastLine({ input, baseLines: base.slice(0, maxLines) });
|
||||||
|
const wrapped = toWrapped(lines);
|
||||||
|
return { ...wrapped, meta: { fallback_type: 'NONE', overflow_type: 'ELLIPSIS', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
15
client/src/features/textWrap/overflow/index.ts
Normal file
15
client/src/features/textWrap/overflow/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export type {
|
||||||
|
ApplyOverflowFallbackInput,
|
||||||
|
ApplyOverflowFallbackResult,
|
||||||
|
FallbackType,
|
||||||
|
OverflowMeasure,
|
||||||
|
OverflowMode,
|
||||||
|
OverflowReason,
|
||||||
|
OverflowType,
|
||||||
|
PartialLayoutInput,
|
||||||
|
PartialLayoutLine,
|
||||||
|
WrapTextMeta,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { applyOverflowFallback } from './fallback';
|
||||||
|
|
||||||
47
client/src/features/textWrap/overflow/types.ts
Normal file
47
client/src/features/textWrap/overflow/types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import type { ContextProfile, FontSpec, MeasureWidthImpl, TextWrapContext } from '../measure/types';
|
||||||
|
|
||||||
|
export type OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT';
|
||||||
|
export type FallbackType = 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT';
|
||||||
|
export type OverflowType = 'NONE' | 'ELLIPSIS' | 'CLIP';
|
||||||
|
|
||||||
|
export type OverflowReason = 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'TOO_LONG' | 'WIDOW' | 'PARTICLE' | string;
|
||||||
|
|
||||||
|
export type PartialLayoutLine = { start: number; end: number; text?: string };
|
||||||
|
export type PartialLayoutInput = { breaks: number[]; lines: PartialLayoutLine[] };
|
||||||
|
|
||||||
|
export type OverflowMeasure = {
|
||||||
|
context: TextWrapContext;
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec: Partial<FontSpec> | null | undefined;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
widgetEnableMeasure?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApplyOverflowFallbackInput = {
|
||||||
|
tokens: Token[];
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
context: TextWrapContext;
|
||||||
|
availableWidth: number;
|
||||||
|
maxLines: number;
|
||||||
|
overflowMode: OverflowMode;
|
||||||
|
ellipsisToken: string;
|
||||||
|
reason: OverflowReason;
|
||||||
|
partialLayout?: PartialLayoutInput | null;
|
||||||
|
measure?: OverflowMeasure;
|
||||||
|
/** TC 标点集合(用于清理 `,…`) */
|
||||||
|
tcPunctuations?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WrapTextMeta = {
|
||||||
|
fallback_type: FallbackType;
|
||||||
|
overflow_type: OverflowType;
|
||||||
|
reason: OverflowReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApplyOverflowFallbackResult = {
|
||||||
|
lines: string[];
|
||||||
|
wrappedText: string;
|
||||||
|
meta: WrapTextMeta;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { Token } from '../../core/types';
|
||||||
|
import { buildTieKey, DEFAULT_LEXICONS, DEFAULT_WEIGHTS, scoreLayout } from '../index';
|
||||||
|
|
||||||
|
function t(text: string, start: number): Token {
|
||||||
|
return { text, start, end: start + text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function lexicographicLess(a: Array<number | string>, b: Array<number | string>): boolean {
|
||||||
|
const n = Math.min(a.length, b.length);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const av = a[i] as any;
|
||||||
|
const bv = b[i] as any;
|
||||||
|
if (av < bv) return true;
|
||||||
|
if (av > bv) return false;
|
||||||
|
}
|
||||||
|
return a.length < b.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap scoring-tiebreak', () => {
|
||||||
|
it('EMOTION_SPLIT:拆分情绪短语 -> 强惩罚 + 追加 spanLen(方案 A)', () => {
|
||||||
|
const tokens: Token[] = [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)];
|
||||||
|
const layoutCandidate = {
|
||||||
|
breaks: [3], // I am so | tired
|
||||||
|
lines: [
|
||||||
|
{ start: 0, end: 3, text: 'I am so', width: 10, tokenCount: 3, charCount: 0 },
|
||||||
|
{ start: 3, end: 4, text: 'tired', width: 10, tokenCount: 1, charCount: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = scoreLayout({
|
||||||
|
tokens,
|
||||||
|
layoutCandidate,
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 100,
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: { ...DEFAULT_LEXICONS, emotionPhrasesEN: ['so tired'] },
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触发强惩罚:-(P_EMOTION_SPLIT + spanLen)
|
||||||
|
const first = res.scoreBreakdown?.terms[0];
|
||||||
|
expect(first?.key).toBe('EMOTION_SPLIT');
|
||||||
|
expect(first?.delta).toBe(-(DEFAULT_WEIGHTS.P_EMOTION_SPLIT + 2));
|
||||||
|
expect(res.flags.emotionSplit).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shift/Accum/Self:EN 词两端带标点也应命中(全词等值匹配)', () => {
|
||||||
|
const tokens: Token[] = [t('but,', 0), t('still', 5), t('ok', 11)];
|
||||||
|
const layoutCandidate = {
|
||||||
|
breaks: [1], // but, | still ok
|
||||||
|
lines: [
|
||||||
|
// 把 width 设置到 idealWidth 上,避免 length 项干扰 debug Top-3
|
||||||
|
{ start: 0, end: 1, text: 'but,', width: 90, tokenCount: 1, charCount: 0 },
|
||||||
|
{ start: 1, end: 3, text: 'still ok', width: 90, tokenCount: 2, charCount: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = scoreLayout({
|
||||||
|
tokens,
|
||||||
|
layoutCandidate,
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 100, // idealWidth=100*0.9=90
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const terms = res.scoreBreakdown?.terms ?? [];
|
||||||
|
expect(terms.some((x) => x.key === 'SHIFT_BREAK')).toBe(true);
|
||||||
|
expect(terms.some((x) => x.key === 'ACCUM_BREAK')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('10.2G:EmotionWord 与 Accum 同时命中 -> Accum 奖励减半(整数)', () => {
|
||||||
|
const tokens: Token[] = [t('already', 0), t('tired', 8)];
|
||||||
|
const layoutCandidate = {
|
||||||
|
breaks: [1], // already | tired
|
||||||
|
lines: [
|
||||||
|
{ start: 0, end: 1, text: 'already', width: 90, tokenCount: 1, charCount: 0 },
|
||||||
|
{ start: 1, end: 2, text: 'tired', width: 90, tokenCount: 1, charCount: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const weights = { ...DEFAULT_WEIGHTS, R_EMOTION_TAIL: 10, R_ACCUM_BREAK: 41 }; // 41/2 -> 20(floor)
|
||||||
|
|
||||||
|
const res = scoreLayout({
|
||||||
|
tokens,
|
||||||
|
layoutCandidate,
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 100,
|
||||||
|
config: { weights, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const terms = res.scoreBreakdown?.terms ?? [];
|
||||||
|
// debug 可能截断为 Top-3,但应包含 EMOTION_TAIL 与 ACCUM_BREAK(顺序确定性)
|
||||||
|
const hasEmotion = terms.some((x) => x.key === 'EMOTION_TAIL' && x.delta === 10);
|
||||||
|
const hasAccumHalf = terms.some((x) => x.key === 'ACCUM_BREAK' && x.delta === Math.floor(41 / 2));
|
||||||
|
expect(hasEmotion).toBe(true);
|
||||||
|
expect(hasAccumHalf).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tieKey:lastLineWidth 更大优先(取 -lastLineWidth)', () => {
|
||||||
|
const base = {
|
||||||
|
breaks: [2],
|
||||||
|
lines: [
|
||||||
|
{ start: 0, end: 2, text: 'a b', width: 50, tokenCount: 2, charCount: 0 },
|
||||||
|
{ start: 2, end: 3, text: 'c', width: 10, tokenCount: 1, charCount: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoredA = { score: 0, flags: {}, tieKey: [] as any };
|
||||||
|
const scoredB = { score: 0, flags: {}, tieKey: [] as any };
|
||||||
|
|
||||||
|
const keyShort = buildTieKey({
|
||||||
|
scoredLayout: scoredA as any,
|
||||||
|
layoutCandidate: base as any,
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
tokenCount: 3,
|
||||||
|
availableWidth: 100,
|
||||||
|
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const layoutLongTail = {
|
||||||
|
...base,
|
||||||
|
lines: [
|
||||||
|
base.lines[0]!,
|
||||||
|
{ ...base.lines[1]!, width: 30 }, // lastLineWidth 更大
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const keyLong = buildTieKey({
|
||||||
|
scoredLayout: scoredB as any,
|
||||||
|
layoutCandidate: layoutLongTail as any,
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
tokenCount: 3,
|
||||||
|
availableWidth: 100,
|
||||||
|
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更优的 tieKey 应“更小”(因为 lastLineWidth 取负)
|
||||||
|
expect(lexicographicLess(keyLong, keyShort)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
21
client/src/features/textWrap/scoring/index.ts
Normal file
21
client/src/features/textWrap/scoring/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export type {
|
||||||
|
LayoutCandidate,
|
||||||
|
LayoutCandidateLine,
|
||||||
|
Lexicons,
|
||||||
|
ScoredLayout,
|
||||||
|
ScoreBreakdown,
|
||||||
|
ScoreLayoutInput,
|
||||||
|
ScoreTerm,
|
||||||
|
ScoringConfig,
|
||||||
|
TextWrapContext,
|
||||||
|
Weights,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { DEFAULT_LEXICONS, mergeLexicons } from './lexicons';
|
||||||
|
export { DEFAULT_WEIGHTS, mergeWeights } from './weights';
|
||||||
|
|
||||||
|
export { preparePhraseTokens, findPhraseSpans, isSpanSplitByBreaks } from './phraseMatch';
|
||||||
|
export { scoreLayout } from './score';
|
||||||
|
export type { BuildTieKeyInput } from './tieKey';
|
||||||
|
export { buildTieKey } from './tieKey';
|
||||||
|
|
||||||
31
client/src/features/textWrap/scoring/lexicons.ts
Normal file
31
client/src/features/textWrap/scoring/lexicons.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Lexicons } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scoring-tiebreak 最小词表(首版写死客户端)
|
||||||
|
*
|
||||||
|
* 来源:`设计说明文档/文档换行算法.md v1.2.1` 第 7 节
|
||||||
|
* 原则:少而准(后续通过打点迭代扩充)
|
||||||
|
*/
|
||||||
|
export const DEFAULT_LEXICONS: Readonly<Lexicons> = Object.freeze({
|
||||||
|
emotionPhrasesTC: [],
|
||||||
|
emotionPhrasesEN: [],
|
||||||
|
protectedPhrases: [],
|
||||||
|
|
||||||
|
shiftWordsTC: ['但', '可是', '然而', '却', '只是', '偏偏'],
|
||||||
|
shiftWordsEN: ['but', 'yet', 'so'],
|
||||||
|
|
||||||
|
accumWordsTC: ['已经', '一直', '曾经', '终于', '还是', '到现在'],
|
||||||
|
accumWordsEN: ['already', 'still', 'even', 'just', 'really'],
|
||||||
|
|
||||||
|
selfWordsTC: ['你', '我', '自己', '我们', '别人'],
|
||||||
|
selfWordsEN: ['you', 'yourself', 'me', 'we'],
|
||||||
|
|
||||||
|
emotionWordsTC: ['累', '痛', '怕', '孤单', '委屈', '撑', '崩溃', '放弃'],
|
||||||
|
emotionWordsEN: ['tired', 'afraid', 'lonely', 'hurt', 'overwhelmed', 'give up'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function mergeLexicons(overrides?: Partial<Lexicons> | null | undefined): Lexicons {
|
||||||
|
if (!overrides) return { ...DEFAULT_LEXICONS };
|
||||||
|
return { ...DEFAULT_LEXICONS, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
92
client/src/features/textWrap/scoring/phraseMatch.ts
Normal file
92
client/src/features/textWrap/scoring/phraseMatch.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Lang, Token } from '../core/types';
|
||||||
|
import { matchENKeyword, normalizeENKeyword, normalizeWhitespace, tokenizeEN } from '../core/index';
|
||||||
|
import { segmentGraphemes } from '../grapheme/index';
|
||||||
|
|
||||||
|
export type PhraseSpan = { start: number; end: number; length: number };
|
||||||
|
|
||||||
|
function toNonEmptyArray(parts: string[]): string[] {
|
||||||
|
return parts.map((s) => s.trim()).filter((s) => s.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preparePhraseTokens(phrase: string, lang: Lang): string[] {
|
||||||
|
const input = String(phrase ?? '');
|
||||||
|
if (input === '') return [];
|
||||||
|
|
||||||
|
if (lang === 'EN') {
|
||||||
|
const { normalizedText } = normalizeWhitespace(input, 'NORMALIZE');
|
||||||
|
const words = normalizedText.split(' ');
|
||||||
|
// EN:按“全词等值匹配”口径做归一化(小写 + 两端去常见标点)
|
||||||
|
return toNonEmptyArray(words).map((w) => normalizeENKeyword(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC:按 grapheme clusters
|
||||||
|
const { clusters } = segmentGraphemes(input, 'PREFERRED');
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenEqualsPhraseToken(tokenText: string, phraseToken: string, lang: Lang): boolean {
|
||||||
|
if (lang === 'EN') return matchENKeyword(tokenText, phraseToken);
|
||||||
|
return tokenText === phraseToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 tokens 中寻找 phraseTokens 的“连续区间完全匹配”(必须)。
|
||||||
|
*
|
||||||
|
* 输出顺序(确定性):
|
||||||
|
* - start 升序
|
||||||
|
* - start 相同:length 降序(更长优先)
|
||||||
|
*/
|
||||||
|
export function findPhraseSpans(tokens: Token[], phraseTokens: string[], lang: Lang): PhraseSpan[] {
|
||||||
|
const n = tokens.length;
|
||||||
|
const m = phraseTokens.length;
|
||||||
|
if (n === 0 || m === 0 || m > n) return [];
|
||||||
|
|
||||||
|
const spans: PhraseSpan[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i <= n - m; i++) {
|
||||||
|
let ok = true;
|
||||||
|
for (let j = 0; j < m; j++) {
|
||||||
|
const t = tokens[i + j]?.text ?? '';
|
||||||
|
const p = phraseTokens[j] ?? '';
|
||||||
|
if (!tokenEqualsPhraseToken(t, p, lang)) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok) spans.push({ start: i, end: i + m, length: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
spans.sort((a, b) => {
|
||||||
|
if (a.start !== b.start) return a.start - b.start;
|
||||||
|
return b.length - a.length;
|
||||||
|
});
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断某个 span 是否被断点拆分到不同的行(即:span 内存在 break)。
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - span=[start,end) 是 token 索引区间
|
||||||
|
* - breaks[i] 是 token 边界索引(1..N-1)
|
||||||
|
* - 若存在 start < b < end,则 span 被拆分
|
||||||
|
*/
|
||||||
|
export function isSpanSplitByBreaks(span: PhraseSpan, breaks: number[]): boolean {
|
||||||
|
const s = span.start;
|
||||||
|
const e = span.end;
|
||||||
|
if (e - s <= 1) return false;
|
||||||
|
for (const b of breaks) {
|
||||||
|
if (b > s && b < e) return true;
|
||||||
|
if (b >= e) break; // breaks 升序:可提前退出
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅用于测试:把 EN 文本按 core-contract 口径 tokenize。
|
||||||
|
*/
|
||||||
|
export function tokenizePhraseLikeInputEN(text: string): Token[] {
|
||||||
|
const { normalizedText } = normalizeWhitespace(text, 'NORMALIZE');
|
||||||
|
return tokenizeEN(normalizedText);
|
||||||
|
}
|
||||||
|
|
||||||
367
client/src/features/textWrap/scoring/score.ts
Normal file
367
client/src/features/textWrap/scoring/score.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import type { Lang, Token } from '../core/types';
|
||||||
|
import { normalizeENKeyword } from '../core/index';
|
||||||
|
|
||||||
|
import type { LayoutCandidateLine, ScoreLayoutInput, ScoreTerm, ScoredLayout, Weights } from './types';
|
||||||
|
import { findPhraseSpans, isSpanSplitByBreaks, preparePhraseTokens } from './phraseMatch';
|
||||||
|
import { mergeLexicons } from './lexicons';
|
||||||
|
import { mergeWeights } from './weights';
|
||||||
|
|
||||||
|
function asInt(n: number): number {
|
||||||
|
// 保证输出整数(避免后续误用浮点)
|
||||||
|
return n | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absInt(n: number): number {
|
||||||
|
return n < 0 ? -n : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToInt(n: number): number {
|
||||||
|
return Math.round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioToBp(ratio: number): number {
|
||||||
|
// 把 0.90 转为 900(bp=1/1000),用于尽量规避浮点带来的跨端差异
|
||||||
|
return Math.round(ratio * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineWidthOrApprox(line: LayoutCandidateLine, lang: Lang): number {
|
||||||
|
if (typeof line.width === 'number' && Number.isFinite(line.width)) return roundToInt(line.width);
|
||||||
|
// width unknown/approx:回退到 tokenCount / charCount(确定性)
|
||||||
|
return lang === 'EN' ? asInt(line.tokenCount) : asInt(line.charCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSet(arr: string[] | undefined, lang: Lang): Set<string> {
|
||||||
|
if (!arr || arr.length === 0) return new Set();
|
||||||
|
if (lang === 'EN') return new Set(arr.map((w) => normalizeENKeyword(w)));
|
||||||
|
return new Set(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenHitSet(tokenText: string, set: Set<string>, lang: Lang): boolean {
|
||||||
|
if (set.size === 0) return false;
|
||||||
|
if (lang === 'EN') {
|
||||||
|
// 全词等值匹配(允许两端标点包裹):normalize 后做等值命中即可
|
||||||
|
return set.has(normalizeENKeyword(tokenText));
|
||||||
|
}
|
||||||
|
return set.has(tokenText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTcPunctuation(t: string, punctSet: Set<string>): boolean {
|
||||||
|
return punctSet.has(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeEmotionTailFlags(tokens: Token[], lines: LayoutCandidateLine[], lang: Lang, emotionWordsSet: Set<string>): boolean[] {
|
||||||
|
const flags: boolean[] = [];
|
||||||
|
if (emotionWordsSet.size === 0) return lines.map(() => false);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const start = Math.max(0, line.start | 0);
|
||||||
|
const end = Math.max(start, line.end | 0);
|
||||||
|
const slice = tokens.slice(start, end);
|
||||||
|
|
||||||
|
if (slice.length === 0) {
|
||||||
|
flags.push(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lang === 'EN') {
|
||||||
|
const last = slice[slice.length - 1]?.text ?? '';
|
||||||
|
flags.push(tokenHitSet(last, emotionWordsSet, 'EN'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC:“最后 2 个 grapheme 范围内命中”
|
||||||
|
const last1 = slice[slice.length - 1]?.text ?? '';
|
||||||
|
const last2 = slice.length >= 2 ? slice[slice.length - 2]?.text ?? '' : '';
|
||||||
|
flags.push(tokenHitSet(last1, emotionWordsSet, 'TC') || tokenHitSet(last2, emotionWordsSet, 'TC'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitBreakdownForDebug(terms: ScoreTerm[]): ScoreTerm[] {
|
||||||
|
// 裁决补充 10.4A:debug Top-3 按规则优先级(10.1 顺序)输出
|
||||||
|
// 这里采用最稳妥的实现:直接取“按实现顺序生成的前 3 项”(确定性且满足优先级排序)
|
||||||
|
return terms.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreLayout(input: ScoreLayoutInput): ScoredLayout {
|
||||||
|
const weights: Weights = mergeWeights(input.config.weights);
|
||||||
|
const lexicons = mergeLexicons(input.lexicons);
|
||||||
|
|
||||||
|
const tokens = input.tokens ?? [];
|
||||||
|
const breaks = (input.layoutCandidate.breaks ?? []).slice().sort((a, b) => a - b);
|
||||||
|
const lines = input.layoutCandidate.lines ?? [];
|
||||||
|
|
||||||
|
const terms: ScoreTerm[] = [];
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
const flags: ScoredLayout['flags'] = {
|
||||||
|
overflowed: Boolean(input.layoutCandidate.meta?.overflowed),
|
||||||
|
fallback: Boolean(input.layoutCandidate.meta?.fallback),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-1 情绪短语拆分惩罚(最高优先)
|
||||||
|
// ------------------------------
|
||||||
|
{
|
||||||
|
let deltaEmotion = 0;
|
||||||
|
let deltaProtected = 0;
|
||||||
|
|
||||||
|
const listEmotion = input.lang === 'EN' ? lexicons.emotionPhrasesEN : lexicons.emotionPhrasesTC;
|
||||||
|
const listProtected = lexicons.protectedPhrases ?? [];
|
||||||
|
|
||||||
|
const pushSplit = (key: 'EMOTION_SPLIT' | 'PROTECTED_SPLIT', phrase: string, spanLen: number) => {
|
||||||
|
const base = key === 'EMOTION_SPLIT' ? weights.P_EMOTION_SPLIT : weights.P_PROTECTED_SPLIT;
|
||||||
|
const d = -asInt(base) - asInt(spanLen); // 方案 A:追加“被拆分短语长度”惩罚(更长短语更强保护)
|
||||||
|
if (key === 'EMOTION_SPLIT') deltaEmotion += d;
|
||||||
|
else deltaProtected += d;
|
||||||
|
|
||||||
|
flags.emotionSplit = true;
|
||||||
|
// 记录 detail 便于治理
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
terms.push({ key, delta: d, detail: { phrase, spanLen } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// emotion phrases
|
||||||
|
for (const phrase of listEmotion) {
|
||||||
|
const phraseTokens = preparePhraseTokens(phrase, input.lang);
|
||||||
|
const spans = findPhraseSpans(tokens, phraseTokens, input.lang);
|
||||||
|
for (const span of spans) {
|
||||||
|
if (isSpanSplitByBreaks(span, breaks)) pushSplit('EMOTION_SPLIT', phrase, span.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// protected phrases
|
||||||
|
for (const phrase of listProtected) {
|
||||||
|
const phraseTokens = preparePhraseTokens(phrase, input.lang);
|
||||||
|
const spans = findPhraseSpans(tokens, phraseTokens, input.lang);
|
||||||
|
for (const span of spans) {
|
||||||
|
if (isSpanSplitByBreaks(span, breaks)) pushSplit('PROTECTED_SPLIT', phrase, span.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:这里已经把 term 推入了 terms(保持“优先级最高的项”最先出现)
|
||||||
|
score += deltaEmotion + deltaProtected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-2 行超长惩罚 + 理想长度奖励
|
||||||
|
// ------------------------------
|
||||||
|
{
|
||||||
|
const idealBp = ratioToBp(input.config.idealWidthRatio[input.context]);
|
||||||
|
const idealWidth = roundToInt((input.availableWidth * idealBp) / 1000);
|
||||||
|
const minPreferredRatio = input.config.minPreferredRatio ?? 0.6;
|
||||||
|
const minPreferredWidth = roundToInt((idealWidth * ratioToBp(minPreferredRatio)) / 1000);
|
||||||
|
|
||||||
|
let overMaxDelta = 0;
|
||||||
|
let idealDelta = 0;
|
||||||
|
let tooShortDelta = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const w = lineWidthOrApprox(line, input.lang);
|
||||||
|
|
||||||
|
if (w > input.availableWidth) {
|
||||||
|
overMaxDelta -= asInt(weights.P_OVER_MAXLEN) * asInt(w - input.availableWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
idealDelta -= absInt(w - idealWidth);
|
||||||
|
|
||||||
|
if (w < minPreferredWidth) {
|
||||||
|
tooShortDelta -= asInt(weights.P_TOO_SHORT) * asInt(minPreferredWidth - w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overMaxDelta !== 0) terms.push({ key: 'OVER_MAXLEN', delta: overMaxDelta, detail: { availableWidth: input.availableWidth } });
|
||||||
|
if (idealDelta !== 0) terms.push({ key: 'IDEAL_LEN', delta: idealDelta, detail: { idealWidth, idealBp } });
|
||||||
|
if (tooShortDelta !== 0) terms.push({ key: 'TOO_SHORT', delta: tooShortDelta, detail: { minPreferredWidth } });
|
||||||
|
|
||||||
|
score += overMaxDelta + idealDelta + tooShortDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-3 widow(EN)/ 单字行(TC)
|
||||||
|
// ------------------------------
|
||||||
|
{
|
||||||
|
const lastLine = lines[lines.length - 1];
|
||||||
|
if (lastLine) {
|
||||||
|
const lastWidth = lineWidthOrApprox(lastLine, input.lang);
|
||||||
|
|
||||||
|
if (input.lang === 'EN') {
|
||||||
|
const tokenCount = asInt(lastLine.tokenCount);
|
||||||
|
if (tokenCount === 1) {
|
||||||
|
const d = -asInt(weights.P_WIDOW_LINE);
|
||||||
|
terms.push({ key: 'WIDOW_LINE', delta: d });
|
||||||
|
score += d;
|
||||||
|
|
||||||
|
const start = Math.max(0, lastLine.start | 0);
|
||||||
|
const end = Math.max(start, lastLine.end | 0);
|
||||||
|
const only = tokens.slice(start, end)[0]?.text ?? '';
|
||||||
|
const norm = normalizeENKeyword(only);
|
||||||
|
const widowMaxLen = asInt(input.config.widowMaxLen ?? 3);
|
||||||
|
if (norm.length > 0 && norm.length <= widowMaxLen) {
|
||||||
|
const d2 = -asInt(weights.P_WIDOW_WORD);
|
||||||
|
terms.push({ key: 'WIDOW_WORD', delta: d2, detail: { word: norm, widowMaxLen } });
|
||||||
|
score += d2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TC:最后一行只有 1 个 grapheme(或 charCount<=1)视为“单字行”
|
||||||
|
const c = asInt(lastLine.charCount);
|
||||||
|
if (c <= 1) {
|
||||||
|
const d = -asInt(weights.P_WIDOW_LINE);
|
||||||
|
terms.push({ key: 'WIDOW_LINE', delta: d, detail: { tcSingleChar: true, lastWidth } });
|
||||||
|
score += d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.2F TC 助词孤立(在 10.1-3 后落地,保证顺序确定性)
|
||||||
|
// ------------------------------
|
||||||
|
if (input.lang === 'TC' && lines.length > 0) {
|
||||||
|
const particles = new Set(input.config.tcParticles ?? ['啊', '喔', '呢', '啦', '吗', '吧', '呀']);
|
||||||
|
const whitelist = new Set(input.config.tcParticleWhitelist ?? ['啊', '喔', '呢', '啦']);
|
||||||
|
|
||||||
|
let delta = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const start = Math.max(0, line.start | 0);
|
||||||
|
const end = Math.max(start, line.end | 0);
|
||||||
|
const slice = tokens.slice(start, end);
|
||||||
|
if (slice.length === 0) continue;
|
||||||
|
|
||||||
|
const first = slice[0]?.text ?? '';
|
||||||
|
const last = slice[slice.length - 1]?.text ?? '';
|
||||||
|
|
||||||
|
const hitFirst = particles.has(first);
|
||||||
|
const hitLast = particles.has(last);
|
||||||
|
if (!hitFirst && !hitLast) continue;
|
||||||
|
|
||||||
|
const base = asInt(weights.P_PARTICLE_ISO);
|
||||||
|
const half = Math.floor(base / 2);
|
||||||
|
|
||||||
|
if (hitFirst) delta -= whitelist.has(first) ? half : base;
|
||||||
|
if (hitLast) delta -= whitelist.has(last) ? half : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta !== 0) {
|
||||||
|
terms.push({ key: 'PARTICLE_ISO', delta, detail: { particles: Array.from(particles), whitelist: Array.from(whitelist) } });
|
||||||
|
score += delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-4 标点断点奖励(TC)
|
||||||
|
// ------------------------------
|
||||||
|
if (input.lang === 'TC') {
|
||||||
|
const puncts = new Set(input.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、']);
|
||||||
|
let delta = 0;
|
||||||
|
|
||||||
|
for (const b of breaks) {
|
||||||
|
const prev = tokens[b - 1]?.text ?? '';
|
||||||
|
if (isTcPunctuation(prev, puncts)) delta += asInt(weights.R_PUNCT_BREAK);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta !== 0) {
|
||||||
|
terms.push({ key: 'PUNCT_BREAK', delta, detail: { tcPunctuations: Array.from(puncts) } });
|
||||||
|
score += delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-5 Shift/Accum/Self 断点奖励(含 EN 行首/行尾感知)
|
||||||
|
// ------------------------------
|
||||||
|
{
|
||||||
|
const shiftSet = buildSet(input.lang === 'EN' ? lexicons.shiftWordsEN : lexicons.shiftWordsTC, input.lang);
|
||||||
|
const accumSet = buildSet(input.lang === 'EN' ? lexicons.accumWordsEN : lexicons.accumWordsTC, input.lang);
|
||||||
|
const selfSet = buildSet(input.lang === 'EN' ? lexicons.selfWordsEN : lexicons.selfWordsTC, input.lang);
|
||||||
|
const emotionSet = buildSet(input.lang === 'EN' ? lexicons.emotionWordsEN : lexicons.emotionWordsTC, input.lang);
|
||||||
|
|
||||||
|
const emotionTailByLine = computeEmotionTailFlags(tokens, lines, input.lang, emotionSet);
|
||||||
|
|
||||||
|
// EmotionWord 落点强化(默认权重为 0,不影响结果;但用于实现 10.2G 的“Accum 减半”规则)
|
||||||
|
let emotionTailDelta = 0;
|
||||||
|
for (let i = 0; i < emotionTailByLine.length; i++) {
|
||||||
|
if (emotionTailByLine[i]) emotionTailDelta += asInt(weights.R_EMOTION_TAIL);
|
||||||
|
}
|
||||||
|
if (emotionTailDelta !== 0) {
|
||||||
|
terms.push({ key: 'EMOTION_TAIL', delta: emotionTailDelta });
|
||||||
|
score += emotionTailDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shiftDelta = 0;
|
||||||
|
let accumDelta = 0;
|
||||||
|
let selfDelta = 0;
|
||||||
|
|
||||||
|
// 为 Accum 减半规则准备:把每个 break 映射到其左右行索引
|
||||||
|
// 假设 lines 与 breaks 一一对应:breaks[i] 分隔 lines[i] 与 lines[i+1]
|
||||||
|
for (let i = 0; i < breaks.length; i++) {
|
||||||
|
const b = breaks[i]!;
|
||||||
|
const prevLast = tokens[b - 1]?.text ?? '';
|
||||||
|
const nextFirst = tokens[b]?.text ?? '';
|
||||||
|
|
||||||
|
const hitShift = tokenHitSet(nextFirst, shiftSet, input.lang) || tokenHitSet(prevLast, shiftSet, input.lang);
|
||||||
|
const hitAccum = tokenHitSet(nextFirst, accumSet, input.lang) || tokenHitSet(prevLast, accumSet, input.lang);
|
||||||
|
const hitSelf = tokenHitSet(nextFirst, selfSet, input.lang) || tokenHitSet(prevLast, selfSet, input.lang);
|
||||||
|
|
||||||
|
if (hitShift) shiftDelta += asInt(weights.R_SHIFT_BREAK);
|
||||||
|
if (hitSelf) selfDelta += asInt(weights.R_SELF_BREAK);
|
||||||
|
|
||||||
|
if (hitAccum) {
|
||||||
|
// 10.2G:若同一行(或同一断点)同时满足 EmotionWord 强化与 Accum 奖励 -> Accum 减半
|
||||||
|
const leftLineIdx = i;
|
||||||
|
const rightLineIdx = i + 1;
|
||||||
|
const emotionHitNearby = Boolean(emotionTailByLine[leftLineIdx]) || Boolean(emotionTailByLine[rightLineIdx]);
|
||||||
|
const base = asInt(weights.R_ACCUM_BREAK);
|
||||||
|
const reward = emotionHitNearby ? Math.floor(base / 2) : base;
|
||||||
|
accumDelta += reward;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shiftDelta !== 0) {
|
||||||
|
terms.push({ key: 'SHIFT_BREAK', delta: shiftDelta });
|
||||||
|
score += shiftDelta;
|
||||||
|
}
|
||||||
|
if (accumDelta !== 0) {
|
||||||
|
terms.push({ key: 'ACCUM_BREAK', delta: accumDelta });
|
||||||
|
score += accumDelta;
|
||||||
|
}
|
||||||
|
if (selfDelta !== 0) {
|
||||||
|
terms.push({ key: 'SELF_BREAK', delta: selfDelta });
|
||||||
|
score += selfDelta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-6 多行视觉均衡(尾行过短惩罚)
|
||||||
|
// ------------------------------
|
||||||
|
{
|
||||||
|
const lastLine = lines[lines.length - 1];
|
||||||
|
if (lastLine) {
|
||||||
|
const idealBp = ratioToBp(input.config.idealWidthRatio[input.context]);
|
||||||
|
const idealWidth = roundToInt((input.availableWidth * idealBp) / 1000);
|
||||||
|
const shortRatio = input.config.shortLastLineRatio ?? 0.5;
|
||||||
|
const shortThreshold = roundToInt((idealWidth * ratioToBp(shortRatio)) / 1000);
|
||||||
|
|
||||||
|
const lastW = lineWidthOrApprox(lastLine, input.lang);
|
||||||
|
if (lastW < shortThreshold) {
|
||||||
|
const d = -asInt(weights.P_SHORT_LASTLINE);
|
||||||
|
terms.push({ key: 'SHORT_LASTLINE', delta: d, detail: { shortThreshold, lastW, idealWidth } });
|
||||||
|
score += d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreBreakdown = input.debug ? { total: score, terms: limitBreakdownForDebug(terms) } : undefined;
|
||||||
|
|
||||||
|
// tieKey 在 tieKey.ts 单独构造(此处返回占位,调用方可覆盖)
|
||||||
|
const tieKey: Array<number | string> = [
|
||||||
|
flags.emotionSplit ? 1 : 0,
|
||||||
|
flags.overflowed ? 1 : 0,
|
||||||
|
// lastLineWidth/spread/idealDistance 等由 buildTieKey 计算;这里仅保证结构存在
|
||||||
|
];
|
||||||
|
|
||||||
|
return { score, flags, tieKey, scoreBreakdown };
|
||||||
|
}
|
||||||
|
|
||||||
96
client/src/features/textWrap/scoring/tieKey.ts
Normal file
96
client/src/features/textWrap/scoring/tieKey.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Lang } from '../core/types';
|
||||||
|
|
||||||
|
import type { LayoutCandidate, LayoutCandidateLine, ScoredLayout, TextWrapContext } from './types';
|
||||||
|
|
||||||
|
function asInt(n: number): number {
|
||||||
|
return n | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToInt(n: number): number {
|
||||||
|
return Math.round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineWidthOrApprox(line: LayoutCandidateLine, lang: Lang): number {
|
||||||
|
if (typeof line.width === 'number' && Number.isFinite(line.width)) return roundToInt(line.width);
|
||||||
|
return lang === 'EN' ? asInt(line.tokenCount) : asInt(line.charCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeIdealBreakPositions(nTokens: number, lineCount: number): number[] {
|
||||||
|
const n = Math.max(0, nTokens | 0);
|
||||||
|
const targetLines = Math.max(1, Math.min(lineCount | 0, n === 0 ? 1 : n));
|
||||||
|
const ideals: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= targetLines - 1; i++) {
|
||||||
|
ideals.push(Math.round((n * i) / targetLines));
|
||||||
|
}
|
||||||
|
|
||||||
|
ideals.sort((a, b) => a - b);
|
||||||
|
return ideals.filter((v, idx) => idx === 0 || v !== ideals[idx - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumAbs(a: number[], b: number[]): number {
|
||||||
|
const m = Math.min(a.length, b.length);
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < m; i++) s += Math.abs((a[i] ?? 0) - (b[i] ?? 0));
|
||||||
|
// 若长度不等,用一个确定性惩罚补齐(避免 NaN,并让更“匹配理想结构”的更优)
|
||||||
|
if (a.length !== b.length) s += 100000 * Math.abs(a.length - b.length);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSpread(widths: number[]): number {
|
||||||
|
if (widths.length === 0) return 0;
|
||||||
|
let min = widths[0]!;
|
||||||
|
let max = widths[0]!;
|
||||||
|
for (const w of widths) {
|
||||||
|
if (w < min) min = w;
|
||||||
|
if (w > max) max = w;
|
||||||
|
}
|
||||||
|
return max - min;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BuildTieKeyInput = {
|
||||||
|
scoredLayout: ScoredLayout;
|
||||||
|
layoutCandidate: LayoutCandidate;
|
||||||
|
lang: Lang;
|
||||||
|
context: TextWrapContext;
|
||||||
|
/** tokens.length(用于理想切分点距离;必须与 breaks/lines 的 token 体系一致) */
|
||||||
|
tokenCount: number;
|
||||||
|
/** 可用宽度(用于理想切分点/尾行宽度判断的辅助项;tieKey 主要使用 width 值) */
|
||||||
|
availableWidth: number;
|
||||||
|
idealWidthRatio: { APP: number; WIDGET: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造 tieKey(按文档 11 节顺序,必须确定性):
|
||||||
|
* 1) emotionSplit=false 优先
|
||||||
|
* 2) overflowed=false 优先
|
||||||
|
* 3) lastLineWidth 更大优先
|
||||||
|
* 4) 行宽分布更均匀优先(max-min 更小)
|
||||||
|
* 5) 断点更接近理想切分点优先(距离之和更小)
|
||||||
|
* 6) breaks 字典序更靠前优先(在外部比较中按逐项数值比较即可)
|
||||||
|
*/
|
||||||
|
export function buildTieKey(args: BuildTieKeyInput): Array<number | string> {
|
||||||
|
const { scoredLayout, layoutCandidate } = args;
|
||||||
|
const lines = layoutCandidate.lines ?? [];
|
||||||
|
const breaks = (layoutCandidate.breaks ?? []).slice().sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const widths = lines.map((l) => lineWidthOrApprox(l, args.lang));
|
||||||
|
const lastLineWidth = widths.length > 0 ? widths[widths.length - 1]! : 0;
|
||||||
|
const spread = computeSpread(widths);
|
||||||
|
|
||||||
|
const ideals = computeIdealBreakPositions(args.tokenCount, lines.length);
|
||||||
|
const idealDist = sumAbs(breaks, ideals);
|
||||||
|
|
||||||
|
const emotionSplitFlag = scoredLayout.flags.emotionSplit ? 1 : 0;
|
||||||
|
const overflowedFlag = scoredLayout.flags.overflowed ? 1 : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
emotionSplitFlag,
|
||||||
|
overflowedFlag,
|
||||||
|
-lastLineWidth, // 更大优先 => 取负数实现“更小更优”
|
||||||
|
spread, // 更小更优
|
||||||
|
idealDist, // 更小更优
|
||||||
|
...breaks,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
92
client/src/features/textWrap/scoring/types.ts
Normal file
92
client/src/features/textWrap/scoring/types.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Lang, Token } from '../core/types';
|
||||||
|
|
||||||
|
export type TextWrapContext = 'APP' | 'WIDGET';
|
||||||
|
|
||||||
|
export type LayoutCandidateLine = {
|
||||||
|
/**
|
||||||
|
* 该行对应的 token 区间(半开区间),单位与 `breaks` 一致:
|
||||||
|
* - start:包含
|
||||||
|
* - end:不包含
|
||||||
|
*/
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
/** 该行展示文本(用于 debug;评分以 token/width 为主) */
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* 该行宽度(同一候选集必须同一单位)。
|
||||||
|
* - App:像素等真实测量单位
|
||||||
|
* - Widget approx:可使用近似单位(例如 wordCount/graphemeCount)
|
||||||
|
*/
|
||||||
|
width: number | null;
|
||||||
|
/** 该行 token 数(EN=词数;TC=grapheme 数) */
|
||||||
|
tokenCount: number;
|
||||||
|
/** 该行字符簇数(TC 使用;EN 可等于 tokenCount 或 0) */
|
||||||
|
charCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayoutCandidate = {
|
||||||
|
/** 断点序列(token 边界索引),升序 */
|
||||||
|
breaks: number[];
|
||||||
|
/** 每行信息(lines.length = breaks.length + 1) */
|
||||||
|
lines: LayoutCandidateLine[];
|
||||||
|
/** 可选:外部模块(overflow/fallback)可透传的标记 */
|
||||||
|
meta?: { overflowed?: boolean; fallback?: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoreTerm = { key: string; delta: number; detail?: any };
|
||||||
|
|
||||||
|
export type ScoreBreakdown = { total: number; terms: ScoreTerm[] };
|
||||||
|
|
||||||
|
export type ScoredLayout = {
|
||||||
|
score: number;
|
||||||
|
flags: { emotionSplit?: boolean; overflowed?: boolean; fallback?: boolean };
|
||||||
|
tieKey: Array<number | string>;
|
||||||
|
scoreBreakdown?: ScoreBreakdown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Weights = Record<string, number>;
|
||||||
|
|
||||||
|
export type Lexicons = {
|
||||||
|
emotionPhrasesTC: string[];
|
||||||
|
emotionPhrasesEN: string[];
|
||||||
|
protectedPhrases?: string[];
|
||||||
|
shiftWordsTC: string[];
|
||||||
|
shiftWordsEN: string[];
|
||||||
|
accumWordsTC: string[];
|
||||||
|
accumWordsEN: string[];
|
||||||
|
selfWordsTC: string[];
|
||||||
|
selfWordsEN: string[];
|
||||||
|
emotionWordsTC?: string[];
|
||||||
|
emotionWordsEN?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoringConfig = {
|
||||||
|
weights: Weights;
|
||||||
|
idealWidthRatio: { APP: number; WIDGET: number };
|
||||||
|
ellipsisToken: string;
|
||||||
|
/** TC 标点集合(用于 PUNCT_BREAK 奖励与行首标点检测) */
|
||||||
|
tcPunctuations?: string[];
|
||||||
|
/** TC 助词集合(用于 PARTICLE_ISO) */
|
||||||
|
tcParticles?: string[];
|
||||||
|
/** TC 语尾语助词白名单:触发 PARTICLE_ISO 时惩罚减半 */
|
||||||
|
tcParticleWhitelist: string[];
|
||||||
|
/** Widow 短词阈值(EN):len(word) <= widowMaxLen(默认 3) */
|
||||||
|
widowMaxLen?: number;
|
||||||
|
/** 过短阈值:minPreferred = idealWidth * minPreferredRatio(默认 0.6) */
|
||||||
|
minPreferredRatio?: number;
|
||||||
|
/** 尾行过短阈值:shortLastLine = idealWidth * shortLastLineRatio(默认 0.5) */
|
||||||
|
shortLastLineRatio?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoreLayoutInput = {
|
||||||
|
tokens: Token[];
|
||||||
|
layoutCandidate: LayoutCandidate;
|
||||||
|
lang: Lang;
|
||||||
|
context: TextWrapContext;
|
||||||
|
/** 可用宽度(同 width 单位),必须由上游提供 */
|
||||||
|
availableWidth: number;
|
||||||
|
config: ScoringConfig;
|
||||||
|
lexicons: Lexicons;
|
||||||
|
debug?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
35
client/src/features/textWrap/scoring/weights.ts
Normal file
35
client/src/features/textWrap/scoring/weights.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Weights } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scoring-tiebreak 默认权重(首版写死客户端)
|
||||||
|
*
|
||||||
|
* 来源:`设计说明文档/文档换行算法.md v1.2.1` 10.3
|
||||||
|
*/
|
||||||
|
export const DEFAULT_WEIGHTS = Object.freeze({
|
||||||
|
P_EMOTION_SPLIT: 10000,
|
||||||
|
P_PROTECTED_SPLIT: 10000,
|
||||||
|
P_WIDOW_WORD: 800,
|
||||||
|
P_WIDOW_LINE: 500,
|
||||||
|
P_SHORT_LASTLINE: 300,
|
||||||
|
P_PARTICLE_ISO: 200,
|
||||||
|
R_PUNCT_BREAK: 80,
|
||||||
|
R_SHIFT_BREAK: 60,
|
||||||
|
R_ACCUM_BREAK: 40,
|
||||||
|
R_SELF_BREAK: 20,
|
||||||
|
P_OVER_MAXLEN: 30,
|
||||||
|
P_TOO_SHORT: 10,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预留:情绪词落点强化(文档 7.2 / 10.2G 提到)
|
||||||
|
* - 为避免偏离 10.3 首版权重,本实现默认置 0(不影响结果)
|
||||||
|
* - 若后续需要启用,可通过 overrides 提供非 0 权重,并用 configVersion 管理
|
||||||
|
*/
|
||||||
|
R_EMOTION_TAIL: 0,
|
||||||
|
P_EMOTION_BURIED: 0,
|
||||||
|
} satisfies Weights);
|
||||||
|
|
||||||
|
export function mergeWeights(overrides?: Partial<Weights> | null | undefined): Weights {
|
||||||
|
if (!overrides) return { ...DEFAULT_WEIGHTS };
|
||||||
|
return { ...DEFAULT_WEIGHTS, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { MeasureWidthImpl } from '../../measure/types';
|
||||||
|
import { searchBestLayoutApp } from '../index';
|
||||||
|
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from '../../scoring/index';
|
||||||
|
|
||||||
|
function t(text: string, start: number) {
|
||||||
|
return { text, start, end: start + text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap search-engine-app', () => {
|
||||||
|
it('确定性:同输入多次调用 breaks/lines 必须一致', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const input = {
|
||||||
|
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
|
||||||
|
lang: 'EN' as const,
|
||||||
|
breakpoints: [
|
||||||
|
{ pos: 1, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 2, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 3, kind: 'SPACE', priority: 10 },
|
||||||
|
],
|
||||||
|
availableWidth: 6,
|
||||||
|
maxLines: 2,
|
||||||
|
lineMode: 'AUTO' as const,
|
||||||
|
measure: {
|
||||||
|
contextProfile: 'APP|ios|test',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: {
|
||||||
|
weights: DEFAULT_WEIGHTS,
|
||||||
|
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||||
|
ellipsisToken: '…',
|
||||||
|
tcParticleWhitelist: [],
|
||||||
|
},
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const a = await searchBestLayoutApp(input as any);
|
||||||
|
const b = await searchBestLayoutApp(input as any);
|
||||||
|
const c = await searchBestLayoutApp(input as any);
|
||||||
|
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
expect(b).toEqual(c);
|
||||||
|
expect(impl).toHaveBeenCalled(); // 有测量发生
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lineMode=FIXED:无解必须返回 NO_CANDIDATE', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const res = await searchBestLayoutApp({
|
||||||
|
tokens: [t('I', 0), t('am', 2)],
|
||||||
|
lang: 'EN',
|
||||||
|
breakpoints: [{ pos: 1, kind: 'SPACE', priority: 10 }],
|
||||||
|
availableWidth: 100,
|
||||||
|
maxLines: 3,
|
||||||
|
lineMode: 'FIXED',
|
||||||
|
measure: {
|
||||||
|
contextProfile: 'APP|ios|fixed',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC H6:断点导致下一行行首为标点时必须禁止', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const res = await searchBestLayoutApp({
|
||||||
|
tokens: [t('我', 0), t('好', 1), t(',', 2), t('累', 3)],
|
||||||
|
lang: 'TC',
|
||||||
|
// 若选择 pos=2,则第二行行首 token 为 ','(应禁止)
|
||||||
|
breakpoints: [{ pos: 2, kind: 'PUNCT', priority: 30 }],
|
||||||
|
availableWidth: 100,
|
||||||
|
maxLines: 2,
|
||||||
|
lineMode: 'FIXED',
|
||||||
|
measure: {
|
||||||
|
contextProfile: 'APP|ios|tc',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], tcPunctuations: [','] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// lineMode=FIXED 且唯一断点被禁止,因此无解
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TOO_LONG:超过阈值直接返回 TOO_LONG', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const tokens = Array.from({ length: 31 }).map((_, i) => t('a', i));
|
||||||
|
const res = await searchBestLayoutApp({
|
||||||
|
tokens,
|
||||||
|
lang: 'EN',
|
||||||
|
breakpoints: [],
|
||||||
|
availableWidth: 100,
|
||||||
|
maxLines: 2,
|
||||||
|
lineMode: 'AUTO',
|
||||||
|
measure: {
|
||||||
|
contextProfile: 'APP|ios|toolong',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.reason).toBe('TOO_LONG');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
21
client/src/features/textWrap/searchApp/constraints.ts
Normal file
21
client/src/features/textWrap/searchApp/constraints.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
|
||||||
|
export const DEFAULT_TOO_LONG_THRESHOLDS = Object.freeze({ EN: 30, TC: 60 });
|
||||||
|
|
||||||
|
export function isTooLong(tokens: Token[], lang: 'TC' | 'EN', thresholds: { EN: number; TC: number }): boolean {
|
||||||
|
const n = Math.max(0, tokens.length | 0);
|
||||||
|
const limit = lang === 'EN' ? thresholds.EN : thresholds.TC;
|
||||||
|
return n > limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLineStartPunctTC(tokens: Token[], pos: number, tcPunctuations: string[]): boolean {
|
||||||
|
const p = pos | 0;
|
||||||
|
if (p <= 0) return false; // 第一行不受 H6 影响
|
||||||
|
const t = tokens[p]?.text ?? '';
|
||||||
|
return tcPunctuations.includes(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOverWidth(width: number, availableWidth: number): boolean {
|
||||||
|
return width > availableWidth;
|
||||||
|
}
|
||||||
|
|
||||||
221
client/src/features/textWrap/searchApp/dpTopK.ts
Normal file
221
client/src/features/textWrap/searchApp/dpTopK.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import type { Breakpoint } from '../breakpoints/types';
|
||||||
|
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
import { measureSliceWidthCached } from '../measure/measureSliceWidthCached';
|
||||||
|
import { buildTieKey, scoreLayout } from '../scoring/index';
|
||||||
|
|
||||||
|
import { DEFAULT_TOO_LONG_THRESHOLDS, isLineStartPunctTC, isOverWidth, isTooLong } from './constraints';
|
||||||
|
import { insertTopK } from './topK';
|
||||||
|
import type { BestLayout, PartialLayout, SearchAppConfig, SearchAppInput, SearchAppResult } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: SearchAppConfig = {
|
||||||
|
topK: 10,
|
||||||
|
tooLongThresholds: DEFAULT_TOO_LONG_THRESHOLDS,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergeConfig(overrides?: Partial<SearchAppConfig> | null): SearchAppConfig {
|
||||||
|
if (!overrides) return { ...DEFAULT_CONFIG };
|
||||||
|
return {
|
||||||
|
topK: overrides.topK ?? DEFAULT_CONFIG.topK,
|
||||||
|
tooLongThresholds: overrides.tooLongThresholds ?? DEFAULT_CONFIG.tooLongThresholds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueSortedPositions(bps: Breakpoint[], n: number): number[] {
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const b of bps) {
|
||||||
|
const p = b.pos | 0;
|
||||||
|
if (p >= 1 && p <= n - 1) set.add(p);
|
||||||
|
}
|
||||||
|
const arr = Array.from(set);
|
||||||
|
arr.sort((a, b) => a - b);
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawSeparatorsForLang(tokensLen: number, lang: 'TC' | 'EN'): string[] | undefined {
|
||||||
|
if (lang !== 'TC') return undefined;
|
||||||
|
// TC:默认拼接不插入额外空格;空格作为 token 自身出现
|
||||||
|
return Array.from({ length: Math.max(0, tokensLen) }, () => '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineText(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
|
||||||
|
return joinTokens(tokens, start, end, rawSeparators);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBestLayout(best: PartialLayout, debug?: boolean): BestLayout {
|
||||||
|
const lines = best.lines.map((l) => l.text);
|
||||||
|
const wrappedText = lines.join('\n');
|
||||||
|
const meta = debug
|
||||||
|
? { breaks: best.breaks, scoreTopTerms: best.scoreTopTerms, score: best.score }
|
||||||
|
: { breaks: best.breaks };
|
||||||
|
return { breaks: best.breaks, lines, wrappedText, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchBestLayoutApp(input: SearchAppInput): Promise<SearchAppResult> {
|
||||||
|
const cfg = mergeConfig(input.config ?? null);
|
||||||
|
const tokens = input.tokens ?? [];
|
||||||
|
const lang = input.lang;
|
||||||
|
const n = tokens.length;
|
||||||
|
|
||||||
|
if (isTooLong(tokens, lang, cfg.tooLongThresholds)) {
|
||||||
|
return { ok: false, reason: 'TOO_LONG', meta: { tokenCount: n } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLines = Math.max(1, input.maxLines | 0);
|
||||||
|
const availableWidth = input.availableWidth;
|
||||||
|
|
||||||
|
const positions = uniqueSortedPositions(input.breakpoints ?? [], n);
|
||||||
|
const rawSep = rawSeparatorsForLang(tokens.length, lang);
|
||||||
|
|
||||||
|
// dp[pos][linesUsed] -> PartialLayout[]
|
||||||
|
const dp: PartialLayout[][][] = Array.from({ length: n + 1 }, () =>
|
||||||
|
Array.from({ length: maxLines + 1 }, () => [])
|
||||||
|
);
|
||||||
|
|
||||||
|
dp[0][0] = [
|
||||||
|
{
|
||||||
|
pos: 0,
|
||||||
|
breaks: [],
|
||||||
|
lines: [],
|
||||||
|
score: 0,
|
||||||
|
tieKey: [0, 0, 0, 0, 0], // 空布局占位,后续会用 buildTieKey 覆盖
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tcPunctuations = input.scoring.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、'];
|
||||||
|
|
||||||
|
// DP 遍历顺序必须固定
|
||||||
|
for (let pos = 0; pos <= n; pos++) {
|
||||||
|
for (let linesUsed = 0; linesUsed <= maxLines - 1; linesUsed++) {
|
||||||
|
const states = dp[pos][linesUsed];
|
||||||
|
if (!states || states.length === 0) continue;
|
||||||
|
|
||||||
|
// 枚举 nextPos:breakpoints 中 >pos 的位置(升序)+ N
|
||||||
|
const nextList: number[] = [];
|
||||||
|
for (const p of positions) if (p > pos) nextList.push(p);
|
||||||
|
if (n > pos) nextList.push(n);
|
||||||
|
|
||||||
|
for (const state of states) {
|
||||||
|
for (const nextPos of nextList) {
|
||||||
|
if (nextPos <= pos) continue; // 禁止空行
|
||||||
|
|
||||||
|
// H6:TC 禁止“行首标点”(断点导致下一行第一个 token 为标点)
|
||||||
|
// 断点位置为 nextPos,因此要检查 tokens[nextPos]
|
||||||
|
if (lang === 'TC' && nextPos < n && isLineStartPunctTC(tokens, nextPos, tcPunctuations)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineText = buildLineText(tokens, pos, nextPos, rawSep);
|
||||||
|
|
||||||
|
const widthRes = await measureSliceWidthCached({
|
||||||
|
tokens,
|
||||||
|
start: pos,
|
||||||
|
end: nextPos,
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: input.measure.contextProfile,
|
||||||
|
fontSpec: input.measure.fontSpec,
|
||||||
|
measureWidthImpl: input.measure.measureWidthImpl,
|
||||||
|
rawSeparators: rawSep,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (widthRes.width === null) {
|
||||||
|
// App 搜索必须依赖宽度派;宽度不可用交给 overflow-fallback 统一处理
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: widthRes.meta.reason === 'MEASURE_FAILED' ? 'MEASURE_FAILED' : 'WIDTH_UNKNOWN',
|
||||||
|
meta: { tokenCount: n },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineWidth = widthRes.width;
|
||||||
|
if (isOverWidth(lineWidth, availableWidth)) continue; // H1
|
||||||
|
|
||||||
|
const tokenCount = nextPos - pos;
|
||||||
|
const charCount = lang === 'TC' ? tokenCount : 0;
|
||||||
|
|
||||||
|
const newLine = {
|
||||||
|
start: pos,
|
||||||
|
end: nextPos,
|
||||||
|
text: lineText,
|
||||||
|
width: lineWidth,
|
||||||
|
tokenCount,
|
||||||
|
charCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newLines = state.lines.concat([newLine]);
|
||||||
|
const newBreaks = nextPos === n ? state.breaks : state.breaks.concat([nextPos]);
|
||||||
|
|
||||||
|
// 构造 layoutCandidate 并评分(为保证确定性,首版直接全量评分)
|
||||||
|
const layoutCandidate = { breaks: newBreaks, lines: newLines };
|
||||||
|
|
||||||
|
const scored = scoreLayout({
|
||||||
|
tokens,
|
||||||
|
layoutCandidate: layoutCandidate as any,
|
||||||
|
lang,
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth,
|
||||||
|
config: input.scoring.config,
|
||||||
|
lexicons: input.scoring.lexicons,
|
||||||
|
debug: Boolean(input.scoring.debug),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tieKey = buildTieKey({
|
||||||
|
scoredLayout: scored as any,
|
||||||
|
layoutCandidate: layoutCandidate as any,
|
||||||
|
lang,
|
||||||
|
context: 'APP',
|
||||||
|
tokenCount: n,
|
||||||
|
availableWidth,
|
||||||
|
idealWidthRatio: input.scoring.config.idealWidthRatio,
|
||||||
|
}) as number[];
|
||||||
|
|
||||||
|
const cand: PartialLayout = {
|
||||||
|
pos: nextPos,
|
||||||
|
breaks: newBreaks,
|
||||||
|
lines: newLines,
|
||||||
|
score: scored.score,
|
||||||
|
tieKey,
|
||||||
|
scoreTopTerms: scored.scoreBreakdown?.terms,
|
||||||
|
};
|
||||||
|
|
||||||
|
dp[nextPos][linesUsed + 1] = insertTopK(dp[nextPos][linesUsed + 1], cand, cfg.topK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束选择
|
||||||
|
const collect: PartialLayout[] = [];
|
||||||
|
if (input.lineMode === 'FIXED') {
|
||||||
|
collect.push(...dp[n][maxLines]);
|
||||||
|
} else {
|
||||||
|
for (let linesUsed = 1; linesUsed <= maxLines; linesUsed++) {
|
||||||
|
collect.push(...dp[n][linesUsed]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collect.length === 0) {
|
||||||
|
return { ok: false, reason: 'NO_CANDIDATE', meta: { tokenCount: n } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopK 容器内本身已排序,但跨不同 linesUsed 需要再全局选最优
|
||||||
|
collect.sort((a, b) => {
|
||||||
|
// 复用 insertTopK 的 compare 规则(在 topK.ts 内部)会更好,但这里直接再排序一次确保稳定
|
||||||
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
const na = a.tieKey;
|
||||||
|
const nb = b.tieKey;
|
||||||
|
const m = Math.min(na.length, nb.length);
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
const av = na[i] ?? 0;
|
||||||
|
const bv = nb[i] ?? 0;
|
||||||
|
if (av < bv) return -1;
|
||||||
|
if (av > bv) return 1;
|
||||||
|
}
|
||||||
|
return na.length - nb.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const best = collect[0]!;
|
||||||
|
return { ok: true, bestLayout: buildBestLayout(best, Boolean(input.scoring.debug)) };
|
||||||
|
}
|
||||||
|
|
||||||
4
client/src/features/textWrap/searchApp/index.ts
Normal file
4
client/src/features/textWrap/searchApp/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type { BestLayout, LineMode, SearchAppConfig, SearchAppInput, SearchAppResult, SearchFailureReason } from './types';
|
||||||
|
|
||||||
|
export { searchBestLayoutApp } from './dpTopK';
|
||||||
|
|
||||||
62
client/src/features/textWrap/searchApp/topK.ts
Normal file
62
client/src/features/textWrap/searchApp/topK.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { PartialLayout } from './types';
|
||||||
|
import { compareBreaksLexicographically } from '../core/index';
|
||||||
|
|
||||||
|
function compareTieKey(a: number[], b: number[]): number {
|
||||||
|
const n = Math.min(a.length, b.length);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const av = a[i] ?? 0;
|
||||||
|
const bv = b[i] ?? 0;
|
||||||
|
if (av < bv) return -1;
|
||||||
|
if (av > bv) return 1;
|
||||||
|
}
|
||||||
|
if (a.length < b.length) return -1;
|
||||||
|
if (a.length > b.length) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TopK 排序(确定性):
|
||||||
|
* - score 越大越优
|
||||||
|
* - tieKey 越小越优(按 11 节构造的数值 key)
|
||||||
|
* - breaks 字典序更小者更优(11.0A)
|
||||||
|
*/
|
||||||
|
export function compareLayouts(a: PartialLayout, b: PartialLayout): number {
|
||||||
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
const t = compareTieKey(a.tieKey, b.tieKey);
|
||||||
|
if (t !== 0) return t;
|
||||||
|
return compareBreaksLexicographically(a.breaks, b.breaks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function breaksKey(breaks: number[]): string {
|
||||||
|
// breaks 序列作为去重 key(确定性)
|
||||||
|
return breaks.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入 TopK(去重 + 排序 + 截断)。
|
||||||
|
*
|
||||||
|
* 去重规则:
|
||||||
|
* - 同 breaks 仅保留最优(score 更高;若相同按 tieKey/breaks 比较)
|
||||||
|
*/
|
||||||
|
export function insertTopK(list: PartialLayout[], cand: PartialLayout, k: number): PartialLayout[] {
|
||||||
|
const K = Math.max(1, k | 0);
|
||||||
|
const out = list.slice();
|
||||||
|
|
||||||
|
const key = breaksKey(cand.breaks);
|
||||||
|
const idx = out.findIndex((x) => breaksKey(x.breaks) === key);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const existing = out[idx]!;
|
||||||
|
if (compareLayouts(cand, existing) < 0) {
|
||||||
|
// cand 更差,忽略
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out[idx] = cand;
|
||||||
|
} else {
|
||||||
|
out.push(cand);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort(compareLayouts);
|
||||||
|
if (out.length > K) out.length = K;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
71
client/src/features/textWrap/searchApp/types.ts
Normal file
71
client/src/features/textWrap/searchApp/types.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import type { Breakpoint } from '../breakpoints/types';
|
||||||
|
import type { ContextProfile, FontSpec, MeasureWidthImpl } from '../measure/types';
|
||||||
|
import type { Lexicons, ScoringConfig, ScoreTerm } from '../scoring/types';
|
||||||
|
|
||||||
|
export type LineMode = 'AUTO' | 'FIXED';
|
||||||
|
|
||||||
|
export type SearchAppConfig = {
|
||||||
|
/** TopK(文档建议 App=10) */
|
||||||
|
topK: number;
|
||||||
|
/** 超长阈值(用于 TOO_LONG) */
|
||||||
|
tooLongThresholds: { EN: number; TC: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchMeasureInput = {
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec: Partial<FontSpec> | null | undefined;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchScoringInput = {
|
||||||
|
config: ScoringConfig;
|
||||||
|
lexicons: Lexicons;
|
||||||
|
debug?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchAppInput = {
|
||||||
|
tokens: Token[];
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
breakpoints: Breakpoint[];
|
||||||
|
availableWidth: number;
|
||||||
|
maxLines: number;
|
||||||
|
lineMode: LineMode;
|
||||||
|
measure: SearchMeasureInput;
|
||||||
|
scoring: SearchScoringInput;
|
||||||
|
config?: Partial<SearchAppConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BestLayout = {
|
||||||
|
breaks: number[];
|
||||||
|
lines: string[];
|
||||||
|
wrappedText: string;
|
||||||
|
meta?: {
|
||||||
|
breaks: number[];
|
||||||
|
scoreTopTerms?: ScoreTerm[];
|
||||||
|
score?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchFailureReason = 'TOO_LONG' | 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | 'NO_CANDIDATE';
|
||||||
|
|
||||||
|
export type SearchAppResult =
|
||||||
|
| { ok: true; bestLayout: BestLayout }
|
||||||
|
| { ok: false; reason: SearchFailureReason; meta?: { tokenCount: number } };
|
||||||
|
|
||||||
|
export type PartialLayout = {
|
||||||
|
pos: number;
|
||||||
|
breaks: number[];
|
||||||
|
lines: Array<{
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
width: number;
|
||||||
|
tokenCount: number;
|
||||||
|
charCount: number;
|
||||||
|
}>;
|
||||||
|
score: number;
|
||||||
|
tieKey: number[];
|
||||||
|
scoreTopTerms?: ScoreTerm[];
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { MeasureWidthImpl } from '../../measure/types';
|
||||||
|
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from '../../scoring/index';
|
||||||
|
import { searchBestLayoutWidget } from '../index';
|
||||||
|
|
||||||
|
function t(text: string, start: number) {
|
||||||
|
return { text, start, end: start + text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap search-engine-widget', () => {
|
||||||
|
it('确定性:同输入多次调用 breaks/lines 必须一致', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const input = {
|
||||||
|
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
|
||||||
|
lang: 'EN' as const,
|
||||||
|
breakpoints: [
|
||||||
|
{ pos: 1, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 2, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 3, kind: 'SPACE', priority: 10 },
|
||||||
|
],
|
||||||
|
availableWidth: 6,
|
||||||
|
maxLines: 2,
|
||||||
|
context: 'WIDGET' as const,
|
||||||
|
measure: {
|
||||||
|
widthMode: 'MEASURE' as const,
|
||||||
|
contextProfile: 'WIDGET|small|test',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
widgetEnableMeasure: true,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: {
|
||||||
|
weights: DEFAULT_WEIGHTS,
|
||||||
|
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||||
|
ellipsisToken: '…',
|
||||||
|
tcParticleWhitelist: [],
|
||||||
|
},
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
config: { beamK: 5, expandM: 12 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const a = await searchBestLayoutWidget(input as any);
|
||||||
|
const b = await searchBestLayoutWidget(input as any);
|
||||||
|
const c = await searchBestLayoutWidget(input as any);
|
||||||
|
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
expect(b).toEqual(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('widthMode=APPROX:仍可输出,并标记 meta.reason=WIDTH_UNKNOWN', async () => {
|
||||||
|
const res = await searchBestLayoutWidget({
|
||||||
|
tokens: [t('我', 0), t('好', 1), t('累', 2)],
|
||||||
|
lang: 'TC',
|
||||||
|
breakpoints: [{ pos: 1, kind: 'BALANCE', priority: 5 }],
|
||||||
|
// APPROX 模式下:availableWidth 与 width 单位都按“token 数”理解
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 2,
|
||||||
|
context: 'WIDGET',
|
||||||
|
measure: { widthMode: 'APPROX' },
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
config: { beamK: 3, expandM: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (res.ok) {
|
||||||
|
expect(res.bestLayout.meta?.reason).toBe('WIDTH_UNKNOWN');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('性能约束:expandM/beamK 生效(通过测量调用次数粗略验证)', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const res = await searchBestLayoutWidget({
|
||||||
|
tokens: [t('a', 0), t('b', 2), t('c', 4), t('d', 6), t('e', 8)],
|
||||||
|
lang: 'EN',
|
||||||
|
breakpoints: [
|
||||||
|
{ pos: 1, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 2, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 3, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 4, kind: 'SPACE', priority: 10 },
|
||||||
|
],
|
||||||
|
availableWidth: 100,
|
||||||
|
maxLines: 3,
|
||||||
|
context: 'WIDGET',
|
||||||
|
measure: {
|
||||||
|
widthMode: 'MEASURE',
|
||||||
|
contextProfile: 'WIDGET|perf',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
widgetEnableMeasure: true,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
},
|
||||||
|
config: { beamK: 2, expandM: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
// 每轮最多 beamK 个 beam,每个 beam 最多 expandM 次扩展 -> 粗略上限:maxLines*beamK*expandM
|
||||||
|
expect((impl as any).mock.calls.length).toBeLessThanOrEqual(3 * 2 * 2 + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC H6:行首标点导致的转移必须被禁止', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const res = await searchBestLayoutWidget({
|
||||||
|
tokens: [t('我', 0), t('好', 1), t(',', 2), t('累', 3)],
|
||||||
|
lang: 'TC',
|
||||||
|
breakpoints: [{ pos: 2, kind: 'PUNCT', priority: 30 }],
|
||||||
|
// 让“一行放下全文”不可能(触发超宽过滤),从而必须依赖断点切分
|
||||||
|
availableWidth: 3,
|
||||||
|
maxLines: 2,
|
||||||
|
context: 'WIDGET',
|
||||||
|
measure: {
|
||||||
|
widthMode: 'MEASURE',
|
||||||
|
contextProfile: 'WIDGET|tc',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
widgetEnableMeasure: true,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], tcPunctuations: [','] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
},
|
||||||
|
config: { beamK: 3, expandM: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
263
client/src/features/textWrap/searchWidget/beam.ts
Normal file
263
client/src/features/textWrap/searchWidget/beam.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import type { Breakpoint } from '../breakpoints/types';
|
||||||
|
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
import { measureSliceWidthCached } from '../measure/measureSliceWidthCached';
|
||||||
|
import { buildTieKey, scoreLayout } from '../scoring/index';
|
||||||
|
|
||||||
|
import { approxWidth, DEFAULT_TOO_LONG_THRESHOLDS, isLineStartPunctTC, isTooLong } from './constraints';
|
||||||
|
import { insertBeamTopK } from './topK';
|
||||||
|
import type { BestWidgetLayout, SearchWidgetConfig, SearchWidgetInput, SearchWidgetResult, WidgetBeam } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: SearchWidgetConfig = {
|
||||||
|
beamK: 5,
|
||||||
|
expandM: 12,
|
||||||
|
tooLongThresholds: DEFAULT_TOO_LONG_THRESHOLDS,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergeConfig(overrides?: Partial<SearchWidgetConfig> | null): SearchWidgetConfig {
|
||||||
|
if (!overrides) return { ...DEFAULT_CONFIG };
|
||||||
|
return {
|
||||||
|
beamK: overrides.beamK ?? DEFAULT_CONFIG.beamK,
|
||||||
|
expandM: overrides.expandM ?? DEFAULT_CONFIG.expandM,
|
||||||
|
tooLongThresholds: overrides.tooLongThresholds ?? DEFAULT_CONFIG.tooLongThresholds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueSortedPositions(bps: Breakpoint[], n: number): number[] {
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const b of bps) {
|
||||||
|
const p = b.pos | 0;
|
||||||
|
if (p >= 1 && p <= n - 1) set.add(p);
|
||||||
|
}
|
||||||
|
const arr = Array.from(set);
|
||||||
|
arr.sort((a, b) => a - b);
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawSeparatorsForLang(tokensLen: number, lang: 'TC' | 'EN'): string[] | undefined {
|
||||||
|
if (lang !== 'TC') return undefined;
|
||||||
|
// TC:默认拼接不插入额外空格;空格 token 自己决定展示
|
||||||
|
return Array.from({ length: Math.max(0, tokensLen) }, () => '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineText(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
|
||||||
|
return joinTokens(tokens, start, end, rawSeparators);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeApproxReason(a?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED', b?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED') {
|
||||||
|
if (a === 'MEASURE_FAILED' || b === 'MEASURE_FAILED') return 'MEASURE_FAILED';
|
||||||
|
if (a === 'WIDTH_UNKNOWN' || b === 'WIDTH_UNKNOWN') return 'WIDTH_UNKNOWN';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBestLayout(best: WidgetBeam, debug?: boolean): BestWidgetLayout {
|
||||||
|
const lines = best.lines.map((l) => l.text);
|
||||||
|
const wrappedText = lines.join('\n');
|
||||||
|
|
||||||
|
const reason = best.lines.reduce<'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | undefined>((acc, l) => {
|
||||||
|
return mergeApproxReason(acc, l.approxReason);
|
||||||
|
}, undefined);
|
||||||
|
|
||||||
|
const meta = debug
|
||||||
|
? { breaks: best.breaks, scoreTopTerms: best.scoreTopTerms, score: best.score, reason }
|
||||||
|
: { breaks: best.breaks, reason };
|
||||||
|
|
||||||
|
return { breaks: best.breaks, lines, wrappedText, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchBestLayoutWidget(input: SearchWidgetInput): Promise<SearchWidgetResult> {
|
||||||
|
const cfg = mergeConfig(input.config ?? null);
|
||||||
|
const tokens = input.tokens ?? [];
|
||||||
|
const lang = input.lang;
|
||||||
|
const n = tokens.length;
|
||||||
|
|
||||||
|
if (isTooLong(tokens, lang, cfg.tooLongThresholds)) {
|
||||||
|
return { ok: false, reason: 'TOO_LONG', meta: { tokenCount: n } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLines = Math.max(1, input.maxLines | 0);
|
||||||
|
const availableWidth = input.availableWidth;
|
||||||
|
|
||||||
|
const positions = uniqueSortedPositions(input.breakpoints ?? [], n);
|
||||||
|
const rawSep = rawSeparatorsForLang(tokens.length, lang);
|
||||||
|
|
||||||
|
const tcPunctuations = input.scoring.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、'];
|
||||||
|
|
||||||
|
// 初始 beams
|
||||||
|
let beams: WidgetBeam[] = [
|
||||||
|
{ pos: 0, breaks: [], lines: [], score: 0, tieKey: [0, 0, 0, 0, 0] },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let lineIndex = 1; lineIndex <= maxLines; lineIndex++) {
|
||||||
|
let newBeams: WidgetBeam[] = [];
|
||||||
|
|
||||||
|
// 关键:保留已完成(pos==N)的 beams,避免“提前完成的解”在后续轮次被丢弃
|
||||||
|
// 否则会错误地倾向“凑满 maxLines 行”的解,造成断句很怪(例如把“村莊”拆开)
|
||||||
|
for (const b of beams) {
|
||||||
|
if (b.pos === n) {
|
||||||
|
newBeams = insertBeamTopK(newBeams, b, cfg.beamK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// beams 顺序固定:按当前排序后的顺序扩展
|
||||||
|
//(insertBeamTopK 会保持排序;这里再 sort 一次防御)
|
||||||
|
beams = beams.slice().sort((a, b) => {
|
||||||
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
const na = a.tieKey;
|
||||||
|
const nb = b.tieKey;
|
||||||
|
const m = Math.min(na.length, nb.length);
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
const av = na[i] ?? 0;
|
||||||
|
const bv = nb[i] ?? 0;
|
||||||
|
if (av < bv) return -1;
|
||||||
|
if (av > bv) return 1;
|
||||||
|
}
|
||||||
|
return na.length - nb.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const beam of beams) {
|
||||||
|
const pos = beam.pos;
|
||||||
|
if (pos >= n) continue;
|
||||||
|
|
||||||
|
// nextPos:pos 升序 + N
|
||||||
|
const nextList: number[] = [];
|
||||||
|
for (const p of positions) if (p > pos) nextList.push(p);
|
||||||
|
if (n > pos) nextList.push(n);
|
||||||
|
|
||||||
|
// expandM 裁剪:只取前 M 个(确定性:按 pos 升序),但必须保证 N(结束边界)始终可选
|
||||||
|
// 否则会出现“明明一行就能结束,却被裁剪掉只能继续拆分”的怪异换行。
|
||||||
|
const M = Math.max(1, cfg.expandM | 0);
|
||||||
|
let trimmed: number[];
|
||||||
|
if (M === 1) {
|
||||||
|
trimmed = [n];
|
||||||
|
} else {
|
||||||
|
const withoutN = nextList.filter((x) => x !== n);
|
||||||
|
trimmed = withoutN.slice(0, M - 1);
|
||||||
|
trimmed.push(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nextPos of trimmed) {
|
||||||
|
if (nextPos <= pos) continue; // 禁止空行
|
||||||
|
|
||||||
|
// H6:TC 禁止行首标点(断点导致下一行第一个 token 为标点)
|
||||||
|
if (lang === 'TC' && nextPos < n && isLineStartPunctTC(tokens, nextPos, tcPunctuations)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenCount = nextPos - pos;
|
||||||
|
const charCount = lang === 'TC' ? tokenCount : 0;
|
||||||
|
const lineText = buildLineText(tokens, pos, nextPos, rawSep);
|
||||||
|
|
||||||
|
let lineWidth: number;
|
||||||
|
let isApprox = false;
|
||||||
|
let approxReason: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | undefined;
|
||||||
|
let widthTrusted = false;
|
||||||
|
|
||||||
|
if (input.measure.widthMode === 'APPROX') {
|
||||||
|
isApprox = true;
|
||||||
|
approxReason = 'WIDTH_UNKNOWN';
|
||||||
|
lineWidth = approxWidth(tokenCount, charCount, lang);
|
||||||
|
} else {
|
||||||
|
const widgetEnableMeasure = input.measure.widgetEnableMeasure ?? true;
|
||||||
|
const res = await measureSliceWidthCached({
|
||||||
|
tokens,
|
||||||
|
start: pos,
|
||||||
|
end: nextPos,
|
||||||
|
context: 'WIDGET',
|
||||||
|
contextProfile: input.measure.contextProfile,
|
||||||
|
fontSpec: input.measure.fontSpec,
|
||||||
|
measureWidthImpl: input.measure.measureWidthImpl,
|
||||||
|
widgetEnableMeasure,
|
||||||
|
rawSeparators: rawSep,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.width === null) {
|
||||||
|
isApprox = true;
|
||||||
|
approxReason = res.meta.reason ?? 'WIDTH_UNKNOWN';
|
||||||
|
lineWidth = approxWidth(tokenCount, charCount, lang);
|
||||||
|
} else {
|
||||||
|
widthTrusted = true;
|
||||||
|
lineWidth = res.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超宽过滤:仅当宽度可信时执行
|
||||||
|
if (widthTrusted && lineWidth > availableWidth) continue;
|
||||||
|
|
||||||
|
const newLine = {
|
||||||
|
start: pos,
|
||||||
|
end: nextPos,
|
||||||
|
text: lineText,
|
||||||
|
width: lineWidth,
|
||||||
|
tokenCount,
|
||||||
|
charCount,
|
||||||
|
isApprox,
|
||||||
|
approxReason,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newLines = beam.lines.concat([newLine]);
|
||||||
|
const newBreaks = nextPos === n ? beam.breaks : beam.breaks.concat([nextPos]);
|
||||||
|
const layoutCandidate = { breaks: newBreaks, lines: newLines.map(({ isApprox: _a, approxReason: _b, ...rest }) => rest) };
|
||||||
|
|
||||||
|
const scored = scoreLayout({
|
||||||
|
tokens,
|
||||||
|
layoutCandidate: layoutCandidate as any,
|
||||||
|
lang,
|
||||||
|
context: 'WIDGET',
|
||||||
|
availableWidth,
|
||||||
|
config: input.scoring.config,
|
||||||
|
lexicons: input.scoring.lexicons,
|
||||||
|
debug: Boolean(input.scoring.debug),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tieKey = buildTieKey({
|
||||||
|
scoredLayout: scored as any,
|
||||||
|
layoutCandidate: layoutCandidate as any,
|
||||||
|
lang,
|
||||||
|
context: 'WIDGET',
|
||||||
|
tokenCount: n,
|
||||||
|
availableWidth,
|
||||||
|
idealWidthRatio: input.scoring.config.idealWidthRatio,
|
||||||
|
}) as number[];
|
||||||
|
|
||||||
|
const cand: WidgetBeam = {
|
||||||
|
pos: nextPos,
|
||||||
|
breaks: newBreaks,
|
||||||
|
lines: newLines,
|
||||||
|
score: scored.score,
|
||||||
|
tieKey,
|
||||||
|
scoreTopTerms: scored.scoreBreakdown?.terms,
|
||||||
|
};
|
||||||
|
|
||||||
|
newBeams = insertBeamTopK(newBeams, cand, cfg.beamK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beams = newBeams;
|
||||||
|
if (beams.length === 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finished = beams.filter((b) => b.pos === n);
|
||||||
|
if (finished.length === 0) {
|
||||||
|
return { ok: false, reason: 'NO_CANDIDATE', meta: { tokenCount: n } };
|
||||||
|
}
|
||||||
|
|
||||||
|
finished.sort((a, b) => {
|
||||||
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
const na = a.tieKey;
|
||||||
|
const nb = b.tieKey;
|
||||||
|
const m = Math.min(na.length, nb.length);
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
const av = na[i] ?? 0;
|
||||||
|
const bv = nb[i] ?? 0;
|
||||||
|
if (av < bv) return -1;
|
||||||
|
if (av > bv) return 1;
|
||||||
|
}
|
||||||
|
return na.length - nb.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const best = finished[0]!;
|
||||||
|
return { ok: true, bestLayout: buildBestLayout(best, Boolean(input.scoring.debug)) };
|
||||||
|
}
|
||||||
|
|
||||||
21
client/src/features/textWrap/searchWidget/constraints.ts
Normal file
21
client/src/features/textWrap/searchWidget/constraints.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
|
||||||
|
export const DEFAULT_TOO_LONG_THRESHOLDS = Object.freeze({ EN: 30, TC: 60 });
|
||||||
|
|
||||||
|
export function isTooLong(tokens: Token[], lang: 'TC' | 'EN', thresholds: { EN: number; TC: number }): boolean {
|
||||||
|
const n = Math.max(0, tokens.length | 0);
|
||||||
|
const limit = lang === 'EN' ? thresholds.EN : thresholds.TC;
|
||||||
|
return n > limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLineStartPunctTC(tokens: Token[], nextPos: number, tcPunctuations: string[]): boolean {
|
||||||
|
const p = nextPos | 0;
|
||||||
|
if (p <= 0) return false;
|
||||||
|
const t = tokens[p]?.text ?? '';
|
||||||
|
return tcPunctuations.includes(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approxWidth(tokenCount: number, charCount: number, lang: 'TC' | 'EN'): number {
|
||||||
|
return lang === 'EN' ? tokenCount : charCount;
|
||||||
|
}
|
||||||
|
|
||||||
13
client/src/features/textWrap/searchWidget/index.ts
Normal file
13
client/src/features/textWrap/searchWidget/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export type {
|
||||||
|
BestWidgetLayout,
|
||||||
|
SearchWidgetConfig,
|
||||||
|
SearchWidgetFailureReason,
|
||||||
|
SearchWidgetInput,
|
||||||
|
SearchWidgetMeasureInput,
|
||||||
|
SearchWidgetResult,
|
||||||
|
WidthMode,
|
||||||
|
WidgetBeam,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { searchBestLayoutWidget } from './beam';
|
||||||
|
|
||||||
52
client/src/features/textWrap/searchWidget/topK.ts
Normal file
52
client/src/features/textWrap/searchWidget/topK.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { compareBreaksLexicographically } from '../core/index';
|
||||||
|
import type { WidgetBeam } from './types';
|
||||||
|
|
||||||
|
function compareTieKey(a: number[], b: number[]): number {
|
||||||
|
const n = Math.min(a.length, b.length);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const av = a[i] ?? 0;
|
||||||
|
const bv = b[i] ?? 0;
|
||||||
|
if (av < bv) return -1;
|
||||||
|
if (av > bv) return 1;
|
||||||
|
}
|
||||||
|
if (a.length < b.length) return -1;
|
||||||
|
if (a.length > b.length) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BeamK 排序(确定性):
|
||||||
|
* - score 越大越优
|
||||||
|
* - tieKey 越小越优
|
||||||
|
* - breaks 字典序更小者更优(11.0A)
|
||||||
|
*/
|
||||||
|
export function compareBeams(a: WidgetBeam, b: WidgetBeam): number {
|
||||||
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
const t = compareTieKey(a.tieKey, b.tieKey);
|
||||||
|
if (t !== 0) return t;
|
||||||
|
return compareBreaksLexicographically(a.breaks, b.breaks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function breaksKey(breaks: number[]): string {
|
||||||
|
return breaks.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertBeamTopK(list: WidgetBeam[], cand: WidgetBeam, k: number): WidgetBeam[] {
|
||||||
|
const K = Math.max(1, k | 0);
|
||||||
|
const out = list.slice();
|
||||||
|
|
||||||
|
const key = breaksKey(cand.breaks);
|
||||||
|
const idx = out.findIndex((x) => breaksKey(x.breaks) === key);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const existing = out[idx]!;
|
||||||
|
if (compareBeams(cand, existing) < 0) return out;
|
||||||
|
out[idx] = cand;
|
||||||
|
} else {
|
||||||
|
out.push(cand);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort(compareBeams);
|
||||||
|
if (out.length > K) out.length = K;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
91
client/src/features/textWrap/searchWidget/types.ts
Normal file
91
client/src/features/textWrap/searchWidget/types.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import type { Breakpoint } from '../breakpoints/types';
|
||||||
|
import type { ContextProfile, FontSpec, MeasureWidthImpl } from '../measure/types';
|
||||||
|
import type { Lexicons, ScoringConfig, ScoreTerm } from '../scoring/types';
|
||||||
|
|
||||||
|
export type WidthMode = 'MEASURE' | 'APPROX';
|
||||||
|
|
||||||
|
export type SearchWidgetConfig = {
|
||||||
|
beamK: number;
|
||||||
|
expandM: number;
|
||||||
|
tooLongThresholds: { EN: number; TC: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWidgetMeasureInput =
|
||||||
|
| { widthMode: 'APPROX' }
|
||||||
|
| {
|
||||||
|
widthMode: 'MEASURE';
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec: Partial<FontSpec> | null | undefined;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
/** Widget 是否启用测量;默认 true(本模块仅在明确 MEASURE 时开启) */
|
||||||
|
widgetEnableMeasure?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWidgetScoringInput = {
|
||||||
|
config: ScoringConfig;
|
||||||
|
lexicons: Lexicons;
|
||||||
|
debug?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWidgetInput = {
|
||||||
|
tokens: Token[];
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
breakpoints: Breakpoint[];
|
||||||
|
/**
|
||||||
|
* 可用宽度(与 line.width 的单位必须一致):
|
||||||
|
* - MEASURE:像素等真实测量单位
|
||||||
|
* - APPROX:建议用“近似单位”(例如每行可容纳的 token 数)
|
||||||
|
*/
|
||||||
|
availableWidth: number;
|
||||||
|
maxLines: number;
|
||||||
|
context: 'WIDGET';
|
||||||
|
measure: SearchWidgetMeasureInput;
|
||||||
|
scoring: SearchWidgetScoringInput;
|
||||||
|
config?: Partial<SearchWidgetConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetLayoutMeta = {
|
||||||
|
breaks: number[];
|
||||||
|
scoreTopTerms?: ScoreTerm[];
|
||||||
|
score?: number;
|
||||||
|
/**
|
||||||
|
* 仅当进入 approx/降级时填入,便于 integration/打点模块统一处理:
|
||||||
|
* - WIDTH_UNKNOWN:未启用测量或测量能力缺失
|
||||||
|
* - MEASURE_FAILED:测量抛错或返回非法
|
||||||
|
*/
|
||||||
|
reason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BestWidgetLayout = {
|
||||||
|
breaks: number[];
|
||||||
|
lines: string[];
|
||||||
|
wrappedText: string;
|
||||||
|
meta?: WidgetLayoutMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWidgetFailureReason = 'TOO_LONG' | 'NO_CANDIDATE';
|
||||||
|
|
||||||
|
export type SearchWidgetResult =
|
||||||
|
| { ok: true; bestLayout: BestWidgetLayout }
|
||||||
|
| { ok: false; reason: SearchWidgetFailureReason; meta?: { tokenCount: number } };
|
||||||
|
|
||||||
|
export type WidgetBeam = {
|
||||||
|
pos: number;
|
||||||
|
breaks: number[];
|
||||||
|
lines: Array<{
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
width: number;
|
||||||
|
tokenCount: number;
|
||||||
|
charCount: number;
|
||||||
|
/** 该行是否为近似宽度(用于 meta.reason) */
|
||||||
|
isApprox: boolean;
|
||||||
|
approxReason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
|
||||||
|
}>;
|
||||||
|
score: number;
|
||||||
|
tieKey: number[];
|
||||||
|
scoreTopTerms?: ScoreTerm[];
|
||||||
|
};
|
||||||
|
|
||||||
71
client/src/features/textWrap/types.ts
Normal file
71
client/src/features/textWrap/types.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { MeasureWidthImpl } from './measure/types';
|
||||||
|
import type { ScoreTerm } from './scoring/types';
|
||||||
|
import type { Weights } 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;
|
||||||
|
/**
|
||||||
|
* 可选评分偏好(用于 UI 场景“更好看”的排版风格)。
|
||||||
|
* - 不传:使用算法默认 v1 权重与口径(更贴近文档 10.3)
|
||||||
|
* - 传入:仅在本次 wrapText 调用内生效,不影响全局
|
||||||
|
*/
|
||||||
|
scoringOverrides?: {
|
||||||
|
/** 覆盖/微调默认权重(整数)。建议只改少数项,例如 TC 的标点断行奖励。 */
|
||||||
|
weights?: Partial<Weights>;
|
||||||
|
/** 覆盖理想行宽比例(0~1)。更小会更倾向“提前换行”。 */
|
||||||
|
idealWidthRatio?: Partial<Record<TextWrapContext, number>>;
|
||||||
|
/** 最短偏好比例(相对 idealWidth)。更小会更宽容“短行”。 */
|
||||||
|
minPreferredRatio?: number;
|
||||||
|
/** 末行过短惩罚阈值比例(相对 idealWidth)。更小会更宽容“短末行”。 */
|
||||||
|
shortLastLineRatio?: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 测量缓存隔离 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;
|
||||||
|
};
|
||||||
|
|
||||||
185
client/src/features/textWrap/wrapText.ts
Normal file
185
client/src/features/textWrap/wrapText.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
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 { mergeWeights } from './scoring/weights';
|
||||||
|
import { searchBestLayoutApp } from './searchApp/index';
|
||||||
|
import { searchBestLayoutWidget } from './searchWidget/index';
|
||||||
|
import { applyOverflowFallback } from './overflow/index';
|
||||||
|
|
||||||
|
import type { WrapTextInput, WrapTextMeta, WrapTextOutput } from './types';
|
||||||
|
|
||||||
|
function tokenizeTC(normalizedText: string): Token[] {
|
||||||
|
const { clusters } = segmentGraphemes(normalizedText, 'PREFERRED');
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let idx = 0;
|
||||||
|
for (const c of clusters) {
|
||||||
|
const start = idx;
|
||||||
|
idx += c.length;
|
||||||
|
tokens.push({ text: c, start, end: idx });
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wrapText(input: WrapTextInput): Promise<WrapTextOutput> {
|
||||||
|
const lang = input.lang;
|
||||||
|
const context = input.context;
|
||||||
|
const configVersion = input.configVersion ?? 'v1';
|
||||||
|
const overflowMode: WrapTextInput['overflowMode'] =
|
||||||
|
input.overflowMode ?? (context === 'WIDGET' ? 'ELLIPSIS' : 'CLIP');
|
||||||
|
const lineMode = input.lineMode ?? 'AUTO';
|
||||||
|
const debug = Boolean(input.debug);
|
||||||
|
|
||||||
|
const { normalizedText } = normalizeWhitespace(input.text, 'NORMALIZE');
|
||||||
|
const tokens = lang === 'EN' ? tokenizeEN(normalizedText) : tokenizeTC(normalizedText);
|
||||||
|
|
||||||
|
// 断点候选
|
||||||
|
const { breakpoints } = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang,
|
||||||
|
maxLines: input.maxLines,
|
||||||
|
constraints: input.constraints,
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [',', '。', '!', '?', ';', ':', '、'], balanceRange: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// scoring:protectedPhrases 从 constraints 注入
|
||||||
|
const lexicons = { ...DEFAULT_LEXICONS, protectedPhrases: input.constraints?.protectedPhrases ?? DEFAULT_LEXICONS.protectedPhrases };
|
||||||
|
const tcPunctuations: string[] = [',', '。', '!', '?', ';', ':', '、'];
|
||||||
|
|
||||||
|
// 评分偏好(可选):用于 UI 侧“更好看”的排版风格微调
|
||||||
|
const scoringOverrides = input.scoringOverrides ?? null;
|
||||||
|
const weights = scoringOverrides?.weights ? mergeWeights(scoringOverrides.weights) : DEFAULT_WEIGHTS;
|
||||||
|
const idealWidthRatio = {
|
||||||
|
APP: scoringOverrides?.idealWidthRatio?.APP ?? 0.9,
|
||||||
|
WIDGET: scoringOverrides?.idealWidthRatio?.WIDGET ?? 0.95,
|
||||||
|
};
|
||||||
|
const scoringConfig = {
|
||||||
|
weights,
|
||||||
|
idealWidthRatio,
|
||||||
|
ellipsisToken: '…',
|
||||||
|
tcParticleWhitelist: [],
|
||||||
|
tcPunctuations,
|
||||||
|
// 下面两项默认由 score.ts 内部给出;此处仅在有 overrides 时注入
|
||||||
|
minPreferredRatio: scoringOverrides?.minPreferredRatio,
|
||||||
|
shortLastLineRatio: scoringOverrides?.shortLastLineRatio,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
"errorDesc": "It’s okay if enabling fails. You can keep using the app."
|
"errorDesc": "It’s okay if enabling fails. You can keep using the app."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Mindfulness",
|
"title": "Hey Mama",
|
||||||
"like": "Like",
|
"like": "Like",
|
||||||
"dislike": "Dislike",
|
"dislike": "Dislike",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
"homeScreen": "Home Screen Widget",
|
"homeScreen": "Home Screen Widget",
|
||||||
"howToTitle": "How to add the widget",
|
"howToTitle": "How to add the widget",
|
||||||
"howToDesc1": "Long-press on the Home Screen until the apps jiggle, then tap “+” in the top-left corner.",
|
"howToDesc1": "Long-press on the Home Screen until the apps jiggle, then tap “+” in the top-left corner.",
|
||||||
"howToDesc2": "Search “Mindfulness”, choose a widget size you like, then tap “Add Widget”.",
|
"howToDesc2": "Search “Hey Mama”, choose a widget size you like, then tap “Add Widget”.",
|
||||||
"previewDate": "Thu, Jan 29",
|
"previewDate": "Thu, Jan 29",
|
||||||
"previewQuote": "I’m proud of who I am, even while becoming who I want to be."
|
"previewQuote": "I’m proud of who I am, even while becoming who I want to be."
|
||||||
},
|
},
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"widgetTitle": "iOS Widget",
|
"widgetTitle": "iOS Widget",
|
||||||
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
|
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Hey Mama” → add a size you like."
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"title": "Hey mama.",
|
"title": "Hey mama.",
|
||||||
@@ -260,7 +260,7 @@
|
|||||||
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
|
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "正念",
|
"title": "Hey Mama",
|
||||||
"like": "喜歡",
|
"like": "喜歡",
|
||||||
"dislike": "不喜歡",
|
"dislike": "不喜歡",
|
||||||
"favorites": "收藏",
|
"favorites": "收藏",
|
||||||
@@ -297,9 +297,9 @@
|
|||||||
"widget": {
|
"widget": {
|
||||||
"lockScreen": "鎖屏小工具",
|
"lockScreen": "鎖屏小工具",
|
||||||
"homeScreen": "桌面小工具",
|
"homeScreen": "桌面小工具",
|
||||||
"howToTitle": "如何添加小工具",
|
"howToTitle": "如何加入小工具",
|
||||||
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
|
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
|
||||||
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
|
"howToDesc2": "搜尋「Hey Mama」,選擇喜歡的尺寸,點「加入小工具」。",
|
||||||
"previewDate": "1月29日週四 · 已至臘月十一",
|
"previewDate": "1月29日週四 · 已至臘月十一",
|
||||||
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
||||||
},
|
},
|
||||||
@@ -313,7 +313,7 @@
|
|||||||
"language": "語言",
|
"language": "語言",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"widgetTitle": "iOS 小工具",
|
"widgetTitle": "iOS 小工具",
|
||||||
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
|
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「Hey Mama」 → 添加你喜歡的尺寸。"
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"title": "我們知道,",
|
"title": "我們知道,",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"errorDesc": "It’s okay if enabling fails. You can keep using the app."
|
"errorDesc": "It’s okay if enabling fails. You can keep using the app."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Mindfulness",
|
"title": "Hey Mama",
|
||||||
"like": "Like",
|
"like": "Like",
|
||||||
"dislike": "Dislike",
|
"dislike": "Dislike",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"widgetTitle": "iOS Widget",
|
"widgetTitle": "iOS Widget",
|
||||||
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
|
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Hey Mama” → add a size you like."
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"title": "You Are Perfect.",
|
"title": "You Are Perfect.",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"errorDesc": "No pasa nada si falla. Puedes seguir usando la app."
|
"errorDesc": "No pasa nada si falla. Puedes seguir usando la app."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Mindfulness",
|
"title": "Hey Mama",
|
||||||
"like": "Me gusta",
|
"like": "Me gusta",
|
||||||
"dislike": "No me gusta",
|
"dislike": "No me gusta",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"version": "Versión",
|
"version": "Versión",
|
||||||
"widgetTitle": "Widget de iOS",
|
"widgetTitle": "Widget de iOS",
|
||||||
"widgetDesc": "Pon recordatorios en tu pantalla: mantén pulsado → “+” → busca “Mindfulness” → añade el tamaño."
|
"widgetDesc": "Pon recordatorios en tu pantalla: mantén pulsado → “+” → busca “Hey Mama” → añade el tamaño."
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"agree": "Aceptar y Continuar",
|
"agree": "Aceptar y Continuar",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"errorDesc": "Tudo bem se falhar. Você pode continuar usando o app."
|
"errorDesc": "Tudo bem se falhar. Você pode continuar usando o app."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Mindfulness",
|
"title": "Hey Mama",
|
||||||
"like": "Curtir",
|
"like": "Curtir",
|
||||||
"dislike": "Não curtir",
|
"dislike": "Não curtir",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"version": "Versão",
|
"version": "Versão",
|
||||||
"widgetTitle": "Widget do iOS",
|
"widgetTitle": "Widget do iOS",
|
||||||
"widgetDesc": "Coloque lembretes na tela inicial: pressione e segure → “+” → procure “Mindfulness” → adicione o tamanho."
|
"widgetDesc": "Coloque lembretes na tela inicial: pressione e segure → “+” → procure “Hey Mama” → adicione o tamanho."
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"agree": "Concordar e Continuar",
|
"agree": "Concordar e Continuar",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token,建议用真机测试)。"
|
"errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token,建议用真机测试)。"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "正念",
|
"title": "Hey Mama",
|
||||||
"like": "点赞",
|
"like": "点赞",
|
||||||
"dislike": "讨厌",
|
"dislike": "讨厌",
|
||||||
"favorites": "收藏",
|
"favorites": "收藏",
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
"language": "语言",
|
"language": "语言",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"widgetTitle": "iOS 小组件",
|
"widgetTitle": "iOS 小组件",
|
||||||
"widgetDesc": "把温柔提醒放到桌面上:长按主屏幕 → 点“+” → 搜索“正念” → 添加你喜欢的尺寸。"
|
"widgetDesc": "把温柔提醒放到桌面上:长按主屏幕 → 点“+” → 搜索“Hey Mama” → 添加你喜欢的尺寸。"
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"title": "你本就完美。",
|
"title": "你本就完美。",
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
|
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "正念",
|
"title": "Hey Mama",
|
||||||
"like": "喜歡",
|
"like": "喜歡",
|
||||||
"dislike": "不喜歡",
|
"dislike": "不喜歡",
|
||||||
"favorites": "收藏",
|
"favorites": "收藏",
|
||||||
@@ -129,9 +129,9 @@
|
|||||||
"widget": {
|
"widget": {
|
||||||
"lockScreen": "鎖屏小工具",
|
"lockScreen": "鎖屏小工具",
|
||||||
"homeScreen": "桌面小工具",
|
"homeScreen": "桌面小工具",
|
||||||
"howToTitle": "如何添加小工具",
|
"howToTitle": "如何加入小工具",
|
||||||
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
|
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
|
||||||
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
|
"howToDesc2": "搜尋「Hey Mama」,選擇喜歡的尺寸,點「加入小工具」。",
|
||||||
"previewDate": "1月29日週四 · 已至臘月十一",
|
"previewDate": "1月29日週四 · 已至臘月十一",
|
||||||
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
||||||
},
|
},
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
"language": "語言",
|
"language": "語言",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"widgetTitle": "iOS 小工具",
|
"widgetTitle": "iOS 小工具",
|
||||||
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
|
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「Hey Mama」 → 添加你喜歡的尺寸。"
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"title": "我們知道,",
|
"title": "我們知道,",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fetchRecoWidget } from '@/src/services/recoApi';
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { getUserProfileScoring } from '@/src/storage/appStorage';
|
import { getUserProfileScoring } from '@/src/storage/appStorage';
|
||||||
import { getLocalDayKey } from '@/src/utils/date';
|
import { getLocalDayKey } from '@/src/utils/date';
|
||||||
|
import { wrapText } from '@/src/features/textWrap';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
appGroupGetString,
|
appGroupGetString,
|
||||||
@@ -40,6 +41,12 @@ export type WidgetDailyRecoV1 = {
|
|||||||
item: null | {
|
item: null | {
|
||||||
content_id: number;
|
content_id: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
/**
|
||||||
|
* 预换行文案(由 App 侧使用 Text Wrap 算法生成,写入 App Group,供 Widget 直接渲染)。
|
||||||
|
* - key 以 WidgetFamily 归一化:small/medium/large
|
||||||
|
* - 值使用 `\n` 分行;Widget SwiftUI 的 Text 会按换行符显示
|
||||||
|
*/
|
||||||
|
wrapped_text_by_family?: Partial<Record<'small' | 'medium' | 'large', string>>;
|
||||||
final_score?: number;
|
final_score?: number;
|
||||||
fallback_level_final?: number;
|
fallback_level_final?: number;
|
||||||
};
|
};
|
||||||
@@ -56,6 +63,58 @@ function safeJsonParse<T>(raw: string | null): T | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WidgetFamilyKey = 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
|
function widgetPreset(family: WidgetFamilyKey) {
|
||||||
|
// 与 iOS Widget(`EmotionWidget.swift`)保持一致的显示口径(字体/行数/内边距)
|
||||||
|
// 注意:这里的 widthPt 是“常见 iPhone widget 尺寸”的近似值,用于把 pt 宽度换算为“可容纳 token 数”
|
||||||
|
// 真实渲染仍由系统决定,但这个近似能让算法在不同 family 下更稳定地产出更好看的换行。
|
||||||
|
switch (family) {
|
||||||
|
case 'small':
|
||||||
|
return { widthPt: 155, padding: 14, fontSize: 16, maxLines: 5 };
|
||||||
|
case 'medium':
|
||||||
|
return { widthPt: 329, padding: 16, fontSize: 18, maxLines: 6 };
|
||||||
|
case 'large':
|
||||||
|
return { widthPt: 329, padding: 18, fontSize: 22, maxLines: 8 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function approxCapacityFromPt(args: { lang: 'EN' | 'TC'; usablePt: number; fontSize: number }): number {
|
||||||
|
// 与 wrapText(APP 近似降级) 的换算口径一致:把像素/pt 宽度近似换成“可容纳 token 数”
|
||||||
|
const usable = Math.max(0, args.usablePt);
|
||||||
|
const fs = Math.max(1, Math.round(args.fontSize));
|
||||||
|
const denom = args.lang === 'EN' ? Math.max(1, Math.round(fs * 0.55)) : Math.max(1, Math.round(fs * 0.95));
|
||||||
|
return Math.max(1, Math.floor(usable / denom));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildWrappedTextByFamily(args: { text: string; lang: 'EN' | 'TC' }): Promise<Record<WidgetFamilyKey, string>> {
|
||||||
|
const families: WidgetFamilyKey[] = ['small', 'medium', 'large'];
|
||||||
|
const out: Partial<Record<WidgetFamilyKey, string>> = {};
|
||||||
|
|
||||||
|
for (const f of families) {
|
||||||
|
const p = widgetPreset(f);
|
||||||
|
const usablePt = Math.max(0, p.widthPt - p.padding * 2);
|
||||||
|
const capacity = approxCapacityFromPt({ lang: args.lang, usablePt, fontSize: p.fontSize });
|
||||||
|
|
||||||
|
const res = await wrapText({
|
||||||
|
text: args.text,
|
||||||
|
lang: args.lang,
|
||||||
|
context: 'WIDGET',
|
||||||
|
availableWidth: capacity,
|
||||||
|
maxLines: p.maxLines,
|
||||||
|
overflowMode: 'ELLIPSIS',
|
||||||
|
lineMode: 'AUTO',
|
||||||
|
configVersion: 'v1-widget',
|
||||||
|
debug: false,
|
||||||
|
// Widget 侧不依赖真实测量:默认 APPROX 即可(确保可在 Extension 独立工作)
|
||||||
|
});
|
||||||
|
|
||||||
|
out[f] = res.wrappedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out as Record<WidgetFamilyKey, string>;
|
||||||
|
}
|
||||||
|
|
||||||
function pickUserProfileV1_2(scoringProfile: UserProfileV1_2_Extended): UserProfileV1_2 {
|
function pickUserProfileV1_2(scoringProfile: UserProfileV1_2_Extended): UserProfileV1_2 {
|
||||||
return {
|
return {
|
||||||
profile_version: scoringProfile.profile_version,
|
profile_version: scoringProfile.profile_version,
|
||||||
@@ -126,7 +185,29 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
|
|||||||
|
|
||||||
const today = getLocalDayKey(new Date());
|
const today = getLocalDayKey(new Date());
|
||||||
const cached = await getWidgetDailyRecoCache();
|
const cached = await getWidgetDailyRecoCache();
|
||||||
if (cached?.schema_version === 1 && cached.day_key === today && cached.item?.text) return;
|
// 若今日已有缓存,但缺少预换行字段:补齐后触发 reload(不必请求后端)
|
||||||
|
if (cached?.schema_version === 1 && cached.day_key === today && cached.item?.text) {
|
||||||
|
const hasWrapped = Boolean(cached.item.wrapped_text_by_family && Object.keys(cached.item.wrapped_text_by_family).length > 0);
|
||||||
|
if (hasWrapped) return;
|
||||||
|
|
||||||
|
const wrapLang: 'EN' | 'TC' = cached.lang === 'en' ? 'EN' : 'TC';
|
||||||
|
try {
|
||||||
|
const wrapped = await buildWrappedTextByFamily({ text: cached.item.text, lang: wrapLang });
|
||||||
|
await setWidgetDailyRecoCache({
|
||||||
|
...cached,
|
||||||
|
saved_at: new Date().toISOString(),
|
||||||
|
item: { ...cached.item, wrapped_text_by_family: wrapped },
|
||||||
|
source: cached.source ?? 'app',
|
||||||
|
});
|
||||||
|
await appGroupReloadAllTimelines();
|
||||||
|
} catch (e) {
|
||||||
|
// 预换行失败不阻塞;Widget 仍可用原文 + 系统换行
|
||||||
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
||||||
|
console.log('[DailyWidgetReco] 预换行补齐失败:', args?.reason ?? 'unknown', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const scoringProfile = args?.scoringProfile ?? (await getUserProfileScoring());
|
const scoringProfile = args?.scoringProfile ?? (await getUserProfileScoring());
|
||||||
if (!scoringProfile) return;
|
if (!scoringProfile) return;
|
||||||
@@ -146,6 +227,8 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
|
|||||||
if (!top?.text) return;
|
if (!top?.text) return;
|
||||||
|
|
||||||
const lang = toBackendLocaleFromLanguageTag(i18n.language);
|
const lang = toBackendLocaleFromLanguageTag(i18n.language);
|
||||||
|
const wrapLang: 'EN' | 'TC' = lang === 'en' ? 'EN' : 'TC';
|
||||||
|
const wrapped = await buildWrappedTextByFamily({ text: top.text, lang: wrapLang });
|
||||||
await setWidgetDailyRecoCache({
|
await setWidgetDailyRecoCache({
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
saved_at: new Date().toISOString(),
|
saved_at: new Date().toISOString(),
|
||||||
@@ -155,6 +238,7 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
|
|||||||
item: {
|
item: {
|
||||||
content_id: top.content_id,
|
content_id: top.content_id,
|
||||||
text: top.text,
|
text: top.text,
|
||||||
|
wrapped_text_by_family: wrapped,
|
||||||
final_score: top.final_score,
|
final_score: top.final_score,
|
||||||
fallback_level_final: top.fallback_level_final,
|
fallback_level_final: top.fallback_level_final,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
41
server/app/tasks/ops.py
Normal 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}
|
||||||
|
|
||||||
@@ -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
BIN
server/celerybeat-schedule
Normal file
Binary file not shown.
162
spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md
Normal file
162
spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# breakpoint-candidates(技术计划)
|
||||||
|
|
||||||
|
## 1. 计划目标
|
||||||
|
|
||||||
|
基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,实现“候选断点生成与裁剪”模块,输出**可控规模、确定性排序**的 breakpoints 集合,保证:
|
||||||
|
|
||||||
|
- EN/TC 断点生成口径一致(断点 `pos` 均是 token 边界索引)
|
||||||
|
- 同输入必定同输出(去重、排序、裁剪与过滤全流程确定性)
|
||||||
|
- 候选规模上限严格生效(尤其 TC)
|
||||||
|
- 支持约束过滤:`forbiddenBreakRanges`(必须)与 `protectedPhrases`(可选优化)
|
||||||
|
|
||||||
|
本模块只产出 breakpoints,不做组合搜索与评分。
|
||||||
|
|
||||||
|
## 2. 默认技术决策(本计划采用)
|
||||||
|
|
||||||
|
- **输出结构**:`Array<{ pos, kind, priority }>`,最终按 `pos` 升序
|
||||||
|
- **去重策略**:同一 `pos` 若出现多个来源候选,保留 `priority` 更高者(priority 相同按 `kind` 固定序优先)
|
||||||
|
- **裁剪策略(TC)**:先按“候选重要性排序”截断到 `tcMaxCandidateBreaks`,再按 `pos` 升序输出
|
||||||
|
- **过滤策略**:
|
||||||
|
- 必做:`forbiddenBreakRanges` 命中直接剔除
|
||||||
|
- 可选优化:若已计算 `protectedPhrases` 的 span,可在生成阶段剔除 span 内断点(否则交给评分阶段强惩罚淘汰)
|
||||||
|
|
||||||
|
## 3. 输入/输出与关键口径
|
||||||
|
|
||||||
|
### 3.1 输入(来自上游)
|
||||||
|
|
||||||
|
- `tokens: Token[]`
|
||||||
|
- EN:WORD tokens(不包含 SPACE token)
|
||||||
|
- TC:grapheme cluster tokens(允许包含空格 cluster `" "`,用于 SPACE 断点)
|
||||||
|
- `lang: 'TC' | 'EN'`
|
||||||
|
- `maxLines: number`
|
||||||
|
- `constraints?: { protectedPhrases?: string[]; forbiddenBreakRanges?: Array<{ start: number; end: number }> }`
|
||||||
|
- `config: { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }`
|
||||||
|
|
||||||
|
### 3.2 输出(确定性)
|
||||||
|
|
||||||
|
- `breakpoints: Array<{ pos: number; kind: 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER'; priority: number }>`
|
||||||
|
- `pos`:token 边界索引,范围 `0..N`
|
||||||
|
- **最终输出必须按 `pos` 升序**
|
||||||
|
- `meta?: { pruned: boolean; originalCount: number; finalCount: number }`
|
||||||
|
|
||||||
|
### 3.3 断点边界定义(统一口径)
|
||||||
|
|
||||||
|
- 候选断点只生成在“行内断点”位置:`pos ∈ [1, N-1]`
|
||||||
|
- `pos=0` 与 `pos=N` 由搜索器作为“起止边界”处理(不作为候选断点输出)
|
||||||
|
|
||||||
|
## 4. 生成规则(按语言)
|
||||||
|
|
||||||
|
### 4.1 EN:词边界(kind=SPACE)
|
||||||
|
|
||||||
|
#### 规则
|
||||||
|
|
||||||
|
- tokens 仅为 WORD,不生成 SPACE token
|
||||||
|
- 对每个词边界产生候选断点:
|
||||||
|
- 对 `i in 1..N-1` 生成 `pos=i, kind='SPACE'`
|
||||||
|
- `priority` 固定为基础值(建议 `priority=10`)
|
||||||
|
|
||||||
|
#### 验收要点
|
||||||
|
|
||||||
|
- `"I am so tired"`(N=4)→ breakpoints.pos 必为 `[1,2,3]`(升序)
|
||||||
|
|
||||||
|
### 4.2 TC:标点/空格/BALANCE
|
||||||
|
|
||||||
|
#### 4.2.1 标点后断点(kind=PUNCT,最高优先级)
|
||||||
|
|
||||||
|
- 若 `tokens[i].text` 属于 `tcPunctuations`:
|
||||||
|
- 生成 `pos=i+1, kind='PUNCT'`
|
||||||
|
- `priority` 建议最高(例如 `priority=30`)
|
||||||
|
|
||||||
|
#### 4.2.2 空格后断点(kind=SPACE,中优先级)
|
||||||
|
|
||||||
|
- 若 `tokens[i].text === ' '`:
|
||||||
|
- 生成 `pos=i+1, kind='SPACE'`
|
||||||
|
- `priority` 建议中等(例如 `priority=20`)
|
||||||
|
|
||||||
|
> 注:若上游对 TC 也做了空白 NORMALIZE,则空格通常不会连写,断点仍保持确定性。
|
||||||
|
|
||||||
|
#### 4.2.3 BALANCE 断点(kind=BALANCE,低优先级)
|
||||||
|
|
||||||
|
目的:在无标点时,仍在“接近理想位置”附近提供少量断点,提升可解性与观感。
|
||||||
|
|
||||||
|
**理想位置计算(确定性简化版)**:
|
||||||
|
|
||||||
|
- `N = tokens.length`
|
||||||
|
- `targetLines = min(maxLines, N)`(至少 1,且不超过 N)
|
||||||
|
- 对 `lineIndex in 1..targetLines-1`:
|
||||||
|
- `idealPos = round((N * lineIndex) / targetLines)`
|
||||||
|
- 在区间 `[idealPos - balanceRange, idealPos + balanceRange]` 生成少量候选 `pos`
|
||||||
|
|
||||||
|
**生成细则**:
|
||||||
|
|
||||||
|
- 候选 `pos` 必须落在 `[1, N-1]`
|
||||||
|
- 去重前可以允许重复(后续统一去重)
|
||||||
|
- `priority` 建议最低(例如 `priority=5`)
|
||||||
|
|
||||||
|
> 裁决补充口径:BALANCE 断点允许落在 emotionPhrase/protectedPhrases 的 span 内;是否可用由评分阶段强惩罚决定(本模块不做语义裁决)。
|
||||||
|
|
||||||
|
## 5. 过滤、去重、裁剪与排序(必须确定性)
|
||||||
|
|
||||||
|
### 5.1 forbiddenBreakRanges 过滤(必须)
|
||||||
|
|
||||||
|
- 若 `pos` 落在任一 `forbiddenBreakRanges` 的区间内(按项目约定:`start <= pos <= end` 或半开区间,必须写死一种),则剔除该 breakpoint
|
||||||
|
- 过滤必须发生在最终输出前,保证确定性
|
||||||
|
|
||||||
|
### 5.2 去重(必须)
|
||||||
|
|
||||||
|
同一 `pos` 可能来自多个来源(例如 PUNCT 与 BALANCE):
|
||||||
|
|
||||||
|
- 取 `priority` 更高者
|
||||||
|
- `priority` 相同则按固定 kind 序:`PUNCT > SPACE > BALANCE > OTHER`
|
||||||
|
|
||||||
|
### 5.3 TC 规模上限裁剪(必须)
|
||||||
|
|
||||||
|
当 `lang=TC` 且候选数超过 `tcMaxCandidateBreaks`:
|
||||||
|
|
||||||
|
1. 计算每个 pos 到最近 `idealPos` 的距离 `distToIdeal`(若无 idealPos 列表则设为大值)
|
||||||
|
2. 按以下 key 排序后截断(排序必须固定):
|
||||||
|
- `priority` 降序
|
||||||
|
- `distToIdeal` 升序
|
||||||
|
- `pos` 升序
|
||||||
|
3. 取前 `tcMaxCandidateBreaks`
|
||||||
|
|
||||||
|
最后再按 `pos` 升序输出(输出顺序固定)。
|
||||||
|
|
||||||
|
### 5.4 meta 输出
|
||||||
|
|
||||||
|
- `originalCount`:过滤/去重/裁剪前的候选数量
|
||||||
|
- `finalCount`:最终输出数量
|
||||||
|
- `pruned`:是否发生过裁剪(finalCount < originalCount)
|
||||||
|
|
||||||
|
## 6. 测试计划(Vitest)
|
||||||
|
|
||||||
|
### 6.1 EN 断点生成
|
||||||
|
|
||||||
|
- 输入 tokens=[I, am, so, tired] → pos=[1,2,3](确定性)
|
||||||
|
|
||||||
|
### 6.2 TC 标点/空格断点
|
||||||
|
|
||||||
|
- `tokens=['我','好','累',',','😮💨']` 且 `tcPunctuations` 包含 `,`
|
||||||
|
- 必须包含 `pos=4(kind=PUNCT)`
|
||||||
|
|
||||||
|
### 6.3 BALANCE 与裁剪
|
||||||
|
|
||||||
|
- 构造无标点长文本,balanceRange>0 且 `tcMaxCandidateBreaks` 很小
|
||||||
|
- 验证裁剪后数量上限生效
|
||||||
|
- 验证输出仍按 pos 升序
|
||||||
|
|
||||||
|
### 6.4 forbiddenBreakRanges
|
||||||
|
|
||||||
|
- 给定 ranges,断言命中区间内的 pos 一律被剔除
|
||||||
|
|
||||||
|
### 6.5 确定性(关键)
|
||||||
|
|
||||||
|
- 同输入多次调用 breakpoints 输出完全一致(包括 meta)
|
||||||
|
|
||||||
|
## 7. 完成定义(DoD)
|
||||||
|
|
||||||
|
- EN/TC 候选断点生成口径与裁剪规则写死并实现
|
||||||
|
- 去重/排序/裁剪/过滤流程完全确定性
|
||||||
|
- TC 上限 `tcMaxCandidateBreaks` 生效
|
||||||
|
- 单测覆盖:EN/TC 基础、BALANCE、裁剪、forbiddenBreakRanges、确定性
|
||||||
|
|
||||||
51
spec_kit/Text Wrap/modules/breakpoint-candidates/spec.md
Normal file
51
spec_kit/Text Wrap/modules/breakpoint-candidates/spec.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# breakpoint-candidates(子模块规范)
|
||||||
|
|
||||||
|
## 子模块名称
|
||||||
|
|
||||||
|
breakpoint-candidates(候选断点生成与裁剪)
|
||||||
|
|
||||||
|
## 目标描述
|
||||||
|
|
||||||
|
基于 token 序列生成“可控规模、确定性排序”的候选断点集合(breakpoints),并应用去重、排序与约束过滤,确保后续搜索器复杂度可控且跨端一致。
|
||||||
|
|
||||||
|
本模块输出的是“断点候选集合”,不负责“组合搜索选最优”。
|
||||||
|
|
||||||
|
## 输入/输出定义
|
||||||
|
|
||||||
|
### 输入
|
||||||
|
|
||||||
|
- `tokens: Token[]`
|
||||||
|
- `lang: 'TC' | 'EN'`
|
||||||
|
- `maxLines: number`
|
||||||
|
- `constraints?: { protectedPhrases?: string[]; forbiddenBreakRanges?: Array<{ start: number; end: number }> }`
|
||||||
|
- `config: { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }`
|
||||||
|
|
||||||
|
### 输出
|
||||||
|
|
||||||
|
- `breakpoints: Array<{ pos: number; kind: 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER'; priority: number }>`
|
||||||
|
- **pos**:token 边界索引(0..N)
|
||||||
|
- **排序**:按 `pos` 升序(最终输出必须确定性)
|
||||||
|
- `meta?: { pruned: boolean; originalCount: number; finalCount: number }`
|
||||||
|
|
||||||
|
## 验收标准(可验证)
|
||||||
|
|
||||||
|
- **EN 口径**:
|
||||||
|
- tokens 仅为 WORD(不生成 SPACE token),断点仅存在于词间
|
||||||
|
- 每个词边界产生 `kind=SPACE` 的候选断点(按配置可做裁剪,但必须确定性)
|
||||||
|
- **TC 口径**:
|
||||||
|
- 标点后断点 `kind=PUNCT` 优先级最高
|
||||||
|
- 空格后断点 `kind=SPACE` 次之
|
||||||
|
- BALANCE 断点:围绕理想切分点附近生成少量断点(允许落在短语 span 内,是否可用交给评分惩罚)
|
||||||
|
- **去重/排序/过滤确定性**:
|
||||||
|
- 同一 `pos` 多来源断点:保留 priority 更高者
|
||||||
|
- 输出按 `pos` 升序
|
||||||
|
- `forbiddenBreakRanges` 命中者必定被剔除
|
||||||
|
- **规模上限生效**:
|
||||||
|
- TC 输出候选断点数不超过 `tcMaxCandidateBreaks`
|
||||||
|
- 截断策略确定性(按 priority + 距离理想位置等固定规则)
|
||||||
|
|
||||||
|
## 依赖与关联
|
||||||
|
|
||||||
|
- **依赖**:`core-contract`(token 与索引语义)、`grapheme-segmentation`(TC tokens)
|
||||||
|
- **被依赖**:`search-engine-app`、`search-engine-widget`
|
||||||
|
|
||||||
164
spec_kit/Text Wrap/modules/breakpoint-candidates/tasks.md
Normal file
164
spec_kit/Text Wrap/modules/breakpoint-candidates/tasks.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# breakpoint-candidates(任务清单)
|
||||||
|
|
||||||
|
> 对应计划:`spec_kit/Text Wrap/modules/breakpoint-candidates/plan.md`
|
||||||
|
>
|
||||||
|
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
|
||||||
|
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 任务标记规则
|
||||||
|
|
||||||
|
- 用勾选框标记执行状态:
|
||||||
|
- `[ ]` 未完成
|
||||||
|
- `[x]` 已完成
|
||||||
|
- 每个任务必须可独立验收(有明确产出与检查方式)。
|
||||||
|
- 所有代码注释必须为简体中文,并把“去重/裁剪/排序/过滤”的**确定性口径**写死,避免后续模块漂移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 前置对齐(口径必须写死)
|
||||||
|
|
||||||
|
- [x] 1.1 明确 `pos` 的边界范围:仅输出 `pos ∈ [1, N-1]`
|
||||||
|
- **原因**:`pos=0/N` 属于搜索器的起止边界,不应作为候选断点
|
||||||
|
- **验收**:单测覆盖 `N=0/1/2` 等边界输入,不会输出非法 pos。
|
||||||
|
|
||||||
|
- [x] 1.2 明确 `forbiddenBreakRanges` 的区间口径(写死一种)
|
||||||
|
- **本任务采用**:闭区间 `start <= pos && pos <= end`
|
||||||
|
- **验收**:单测能验证闭区间边界命中(start/end 两端都被剔除)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目录与代码骨架(客户端侧实现)
|
||||||
|
|
||||||
|
- [x] 2.1 新建目录 `client/src/features/textWrap/breakpoints/`
|
||||||
|
- **产出**(建议文件):
|
||||||
|
- `types.ts`(Breakpoint/Config/Constraints)
|
||||||
|
- `generateBreakpoints.ts`(主入口,纯函数)
|
||||||
|
- `tcCandidates.ts`(TC:PUNCT/SPACE/BALANCE 生成)
|
||||||
|
- `enCandidates.ts`(EN:SPACE 断点生成)
|
||||||
|
- `filterAndDedup.ts`(过滤/去重/排序/裁剪)
|
||||||
|
- `__tests__/generateBreakpoints.test.ts`
|
||||||
|
- `index.ts`(统一导出)
|
||||||
|
- **验收**:目录存在,TS 可正常 import(不报路径错误)。
|
||||||
|
|
||||||
|
- [x] 2.2 定义最小类型集合(只覆盖本模块)
|
||||||
|
- **必须包含**:
|
||||||
|
- `Breakpoint = { pos: number; kind: 'PUNCT'|'SPACE'|'BALANCE'|'OTHER'; priority: number }`
|
||||||
|
- `BreakpointMeta = { pruned: boolean; originalCount: number; finalCount: number }`
|
||||||
|
- `Constraints = { forbiddenBreakRanges?: Array<{ start: number; end: number }>; protectedPhrases?: string[] }`
|
||||||
|
- `Config = { tcMaxCandidateBreaks: number; tcPunctuations: string[]; balanceRange: number }`
|
||||||
|
- **验收**:后续实现文件引用类型清晰,且不会引入无关依赖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 断点生成(按语言)
|
||||||
|
|
||||||
|
- [x] 3.1 EN 候选断点生成(kind=SPACE,priority 固定)
|
||||||
|
- **规则**:
|
||||||
|
- 对 `i in 1..N-1` 生成 `pos=i, kind='SPACE'`
|
||||||
|
- `priority=10`(写死)
|
||||||
|
- **验收**:
|
||||||
|
- tokens=[I, am, so, tired] → pos=[1,2,3]
|
||||||
|
|
||||||
|
- [x] 3.2 TC:标点后断点(kind=PUNCT)
|
||||||
|
- **规则**:
|
||||||
|
- 若 `tokens[i].text ∈ tcPunctuations`,生成 `pos=i+1, kind='PUNCT', priority=30`
|
||||||
|
- **验收**:
|
||||||
|
- tokens=['我','好','累',',','😮💨'] → 包含 `pos=4(kind=PUNCT)`
|
||||||
|
|
||||||
|
- [x] 3.3 TC:空格后断点(kind=SPACE)
|
||||||
|
- **规则**:
|
||||||
|
- 若 `tokens[i].text === ' '`,生成 `pos=i+1, kind='SPACE', priority=20`
|
||||||
|
- **验收**:构造含空格 tokens,断点生成稳定且不越界。
|
||||||
|
|
||||||
|
- [x] 3.4 TC:BALANCE 断点生成(kind=BALANCE)
|
||||||
|
- **规则**:
|
||||||
|
- `targetLines = min(maxLines, N)`
|
||||||
|
- 对 `lineIndex in 1..targetLines-1`:
|
||||||
|
- `idealPos = round((N * lineIndex) / targetLines)`
|
||||||
|
- 在 `[idealPos-balanceRange, idealPos+balanceRange]` 内生成 pos(裁剪到 `[1,N-1]`)
|
||||||
|
- `priority=5`
|
||||||
|
- **验收**:
|
||||||
|
- 无标点长文本:可生成接近理想位置的候选断点(数量受控、确定性)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 过滤、去重、裁剪与最终排序(必须确定性)
|
||||||
|
|
||||||
|
- [x] 4.1 forbiddenBreakRanges 过滤(闭区间)
|
||||||
|
- **规则**:命中任一 range 则剔除该 `pos`
|
||||||
|
- **验收**:range 边界 start/end 都会剔除。
|
||||||
|
|
||||||
|
- [x] 4.2 去重:同 pos 只保留一个 breakpoint
|
||||||
|
- **规则**:
|
||||||
|
- priority 更高者优先
|
||||||
|
- priority 相同按 kind 固定序:`PUNCT > SPACE > BALANCE > OTHER`
|
||||||
|
- **验收**:构造同 pos 多来源候选,结果唯一且确定性。
|
||||||
|
|
||||||
|
- [x] 4.3 TC 裁剪:超过 `tcMaxCandidateBreaks` 时截断(确定性排序后截断)
|
||||||
|
- **排序 key(写死)**:
|
||||||
|
- priority 降序
|
||||||
|
- distToIdeal 升序(到最近 idealPos 的距离;无 idealPos 时为大值)
|
||||||
|
- pos 升序
|
||||||
|
- **验收**:
|
||||||
|
- 当候选数 > 上限时,finalCount==tcMaxCandidateBreaks
|
||||||
|
- 截断结果稳定(同输入同输出)
|
||||||
|
|
||||||
|
- [x] 4.4 最终输出排序:按 `pos` 升序
|
||||||
|
- **验收**:无论内部裁剪排序如何,最终输出始终 `pos` 升序。
|
||||||
|
|
||||||
|
- [x] 4.5 meta 输出
|
||||||
|
- **规则**:
|
||||||
|
- `originalCount`:过滤/去重/裁剪前的候选数量
|
||||||
|
- `finalCount`:最终输出数量
|
||||||
|
- `pruned = finalCount < originalCount`
|
||||||
|
- **验收**:单测断言 meta 与候选数量一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 单元测试(Vitest)
|
||||||
|
|
||||||
|
- [x] 5.1 新建 `generateBreakpoints.test.ts`,覆盖 EN 基础用例
|
||||||
|
- **验收**:pos=[1,2,3] 且升序。
|
||||||
|
|
||||||
|
- [x] 5.2 覆盖 TC:PUNCT/SPACE/BALANCE 生成
|
||||||
|
- **验收**:关键样例存在,且 BALANCE 不越界。
|
||||||
|
|
||||||
|
- [x] 5.3 覆盖 forbiddenBreakRanges(闭区间)
|
||||||
|
- **验收**:start/end 命中都剔除。
|
||||||
|
|
||||||
|
- [x] 5.4 覆盖去重与 kind 优先级
|
||||||
|
- **验收**:同 pos 多候选时输出唯一且正确 kind。
|
||||||
|
|
||||||
|
- [x] 5.5 覆盖 TC 裁剪上限与确定性
|
||||||
|
- **验收**:
|
||||||
|
- 数量上限严格生效
|
||||||
|
- 同输入多次调用输出完全一致(包括 meta)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 最终自检清单(合入前)
|
||||||
|
|
||||||
|
- [x] 6.1 `npm test` 通过(包含本模块新增用例)
|
||||||
|
- **验收**:不影响现有测试文件。
|
||||||
|
|
||||||
|
- [x] 6.2 `npx tsc --noEmit` 通过(或项目既有 TS 检查命令通过)
|
||||||
|
- **验收**:无类型错误。
|
||||||
|
|
||||||
|
- [x] 6.3 注释与口径自检(简体中文)
|
||||||
|
- **检查点**:
|
||||||
|
- `pos` 边界范围 `[1,N-1]`
|
||||||
|
- forbiddenBreakRanges 闭区间口径
|
||||||
|
- 去重优先级与裁剪排序 key 的固定顺序
|
||||||
|
- **验收**:后续模块开发者只看代码也不会产生歧义。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 文档回写(任务清单执行完毕后必须做)
|
||||||
|
|
||||||
|
- [x] 7.1 在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充执行状态
|
||||||
|
- **建议写法**:
|
||||||
|
- 增加一行:`- **已完成编码(阶段性)**:breakpoint-candidates(候选断点生成与裁剪)`
|
||||||
|
- **验收**:overview 能反映该子模块已完成,便于全局追踪。
|
||||||
|
|
||||||
111
spec_kit/Text Wrap/modules/core-contract/plan.md
Normal file
111
spec_kit/Text Wrap/modules/core-contract/plan.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# core-contract(技术计划)
|
||||||
|
|
||||||
|
## 1. 计划目标
|
||||||
|
|
||||||
|
基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,落地跨端一致的“基础口径与契约”,为后续断点生成、搜索与评分提供稳定输入与确定性工具,确保:
|
||||||
|
|
||||||
|
- EN/TC 的 **token 索引体系** 与断点 `pos` 语义固定
|
||||||
|
- 文本 **可重组**:任意 `[start..end)` 区间可稳定还原为行文本
|
||||||
|
- EN 关键词命中规则严格为 **全词等值匹配**(避免 substring 误伤)
|
||||||
|
- 空白归一化策略可配置但默认一致(推荐 NORMALIZE)
|
||||||
|
- 提供可复用的 **确定性比较工具**(用于 breaks 字典序/tieKey 比较)
|
||||||
|
|
||||||
|
## 2. 默认技术决策(本计划采用)
|
||||||
|
|
||||||
|
- **空白策略**:默认 `whitespacePolicy=NORMALIZE`
|
||||||
|
- 行为:折叠连续空白为 1 个空格、去首尾空白
|
||||||
|
- 并在 meta(后续模块)中打点 `hadMultiWhitespace`(本模块先预留布尔返回位)
|
||||||
|
- **EN tokenize**:仅生成 WORD token(不生成 SPACE token),以空白分隔;标点按“极简派”保留在词内
|
||||||
|
- **TC tokenize**:本模块只定义接口,具体分割由 `grapheme-segmentation` 提供
|
||||||
|
- **EN 关键词命中**:`lowercase → strip 两端常见标点 → 等值比较`,禁止 contains/substring
|
||||||
|
- **确定性比较**:breaks 字典序比较按“逐项比较 + 公共前缀相同则更短者更小”
|
||||||
|
|
||||||
|
## 3. 目录与产物
|
||||||
|
|
||||||
|
本子模块目录:
|
||||||
|
|
||||||
|
- `spec_kit/Text Wrap/modules/core-contract/spec.md`
|
||||||
|
- `spec_kit/Text Wrap/modules/core-contract/plan.md`(本文)
|
||||||
|
|
||||||
|
建议未来代码落位(实现阶段再定,不在本计划强制):
|
||||||
|
|
||||||
|
- `client/src/features/textWrap/core/`(或 `client/src/utils/textWrap/`)
|
||||||
|
|
||||||
|
## 4. 设计与实现要点(按落地顺序)
|
||||||
|
|
||||||
|
### 4.1 文本预处理:normalizeWhitespace
|
||||||
|
|
||||||
|
实现 `normalizeWhitespace(text) -> { normalizedText, hadMultiWhitespace }`:
|
||||||
|
|
||||||
|
- 规则:
|
||||||
|
- 把任意连续空白(空格/制表/换行等)折叠为单个空格
|
||||||
|
- 去除首尾空白
|
||||||
|
- 注意:
|
||||||
|
- 该规范会改变输入文本;必须作为“算法契约”的一部分固定下来
|
||||||
|
- 若后续产品需要保留原始空白,则走 `PRESERVE` 分支并输出 `rawSeparators`(见 4.3)
|
||||||
|
|
||||||
|
### 4.2 EN tokenize:word tokens(极简派)
|
||||||
|
|
||||||
|
实现 `tokenizeEN(normalizedText) -> tokens[]`:
|
||||||
|
|
||||||
|
- 以空格分隔生成 token
|
||||||
|
- token 只包含 WORD,标点视为词内字符(例如 `tired.`、`Wait...`、`hello—world`、`don't` 都是单 token)
|
||||||
|
- 输出 token 的 `text/start/end`(start/end 为原始或 normalized 的字符区间,需固定口径;建议以 normalizedText 为基准)
|
||||||
|
|
||||||
|
### 4.3 文本重组:joinTokens
|
||||||
|
|
||||||
|
实现 `joinTokens(tokens, start, end, separators?) -> string`:
|
||||||
|
|
||||||
|
- EN 默认:使用单空格 `" "` join `[start..end)` 的 token.text
|
||||||
|
- 若 `whitespacePolicy=PRESERVE`:
|
||||||
|
- 需要 `rawSeparators[i]` 表示 tokens[i] 与 tokens[i+1] 间的原始分隔符
|
||||||
|
- 重组时按 separators 拼接(本计划仅定义接口与行为)
|
||||||
|
|
||||||
|
### 4.4 EN 关键词命中:normalizeENKeyword + matchENKeyword
|
||||||
|
|
||||||
|
实现:
|
||||||
|
|
||||||
|
- `normalizeENKeyword(tokenText) -> string`
|
||||||
|
- `lowercase`
|
||||||
|
- `strip` 两端常见标点(集合需配置化并全端一致,默认参考文档:`, . ! ? : ; " ' … — – ( ) [ ] { }`)
|
||||||
|
- `matchENKeyword(tokenText, keyword) -> boolean`
|
||||||
|
- `normalizeENKeyword(tokenText) === normalizeENKeyword(keyword)`
|
||||||
|
- 禁止 `includes/contains` 类 substring 命中
|
||||||
|
|
||||||
|
### 4.5 断点/区间的索引契约
|
||||||
|
|
||||||
|
固化约定(后续模块必须复用,不得自行发挥):
|
||||||
|
|
||||||
|
- token 索引:`tokens[0..N-1]`
|
||||||
|
- 断点 `pos`:位于 token 边界,切分为 `[0..pos)` 与 `[pos..N)`
|
||||||
|
- 行区间:`[start..end)` 表示 `tokens[start] ... tokens[end-1]`
|
||||||
|
|
||||||
|
## 5. 回归用例与验证方式
|
||||||
|
|
||||||
|
### 5.1 必测示例(EN)
|
||||||
|
|
||||||
|
- `"I am so tired"` → tokens=`[I, am, so, tired]`
|
||||||
|
- `pos=2` → `"I am"` / `"so tired"`
|
||||||
|
- 标点极简派:
|
||||||
|
- `"tired."` 为单 token
|
||||||
|
- 关键词命中:
|
||||||
|
- keyword=`"but"`:`"but,"` 命中;`"rebuttal"` 不命中
|
||||||
|
|
||||||
|
### 5.2 确定性检查
|
||||||
|
|
||||||
|
- 同一输入在同一配置下多次调用:
|
||||||
|
- `normalizedText`、`tokens[]`、`joinTokens()`、`matchENKeyword()` 输出完全一致
|
||||||
|
|
||||||
|
## 6. 风险与规避
|
||||||
|
|
||||||
|
- **start/end 索引口径漂移**:若不同端选择以原始 text 或 normalizedText 计数,可能导致 span 对不齐
|
||||||
|
- 规避:本模块明确 start/end 以 normalizedText 为准(或在实现阶段统一选择一种并写入 README/注释)
|
||||||
|
- **标点集合不一致**:strip 集合若跨端不同会导致命中差异
|
||||||
|
- 规避:将 `punctuationStripSetEN` 写入配置并版本化,禁止散落常量
|
||||||
|
|
||||||
|
## 7. 完成定义(DoD)
|
||||||
|
|
||||||
|
- `core-contract/spec.md` 中定义的输入/输出与验收条目均有可运行的最小实现或可验证的约束说明
|
||||||
|
- EN tokenize / 空白归一化 / 关键词命中 / breaks 字典序比较口径写清楚且可复现
|
||||||
|
- 关键示例用例可在本地/CI 以单元测试或脚本方式验证(实现阶段落地)
|
||||||
|
|
||||||
59
spec_kit/Text Wrap/modules/core-contract/spec.md
Normal file
59
spec_kit/Text Wrap/modules/core-contract/spec.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# core-contract(子模块规范)
|
||||||
|
|
||||||
|
## 子模块名称
|
||||||
|
|
||||||
|
core-contract(核心口径与契约)
|
||||||
|
|
||||||
|
## 目标描述
|
||||||
|
|
||||||
|
定义并固化跨端一致的“基础口径”,为后续断点生成、搜索与评分提供统一契约,避免实现偏差:
|
||||||
|
|
||||||
|
- **索引体系**:EN/TC 的 token 与断点 `pos` 语义
|
||||||
|
- **文本重组**:从 token 区间稳定重组回行文本
|
||||||
|
- **规范化**:空白归一化策略(默认折叠空白、去首尾)
|
||||||
|
- **EN 关键词命中规则**:全词等值匹配(`lowercase → strip 两端常见标点 → 等值比较`),禁止 substring/contains
|
||||||
|
- **配置与版本**:`configVersion` 的语义与回溯字段;全端一致的默认值入口
|
||||||
|
- **确定性比较**:layout tie-break 的字典序比较口径(作为后续模块复用工具)
|
||||||
|
|
||||||
|
本模块不负责“换行搜索”,只负责**定义数据结构与基础函数**。
|
||||||
|
|
||||||
|
## 输入/输出定义
|
||||||
|
|
||||||
|
### 输入
|
||||||
|
|
||||||
|
- `text: string`
|
||||||
|
- `lang: 'TC' | 'EN'`
|
||||||
|
- `options?: { preserveRawSeparators?: boolean }`
|
||||||
|
- `config: { punctuationStripSetEN: string[]; whitespacePolicy: 'NORMALIZE' | 'PRESERVE' }`
|
||||||
|
|
||||||
|
### 输出
|
||||||
|
|
||||||
|
- `normalizedText: string`
|
||||||
|
- `tokens: Array<{ text: string; start: number; end: number }>`
|
||||||
|
- EN:token 仅为 WORD(不产生 SPACE token;标点视为词内字符)
|
||||||
|
- TC:token 为 grapheme cluster(具体分割由 `grapheme-segmentation` 模块实现/提供)
|
||||||
|
- `rawSeparators?: string[]`
|
||||||
|
- 可选:当选择保留原始空白时,输出 token 间分隔符映射
|
||||||
|
- 基础工具函数(逻辑输出):
|
||||||
|
- `joinTokens(start, end) -> string`
|
||||||
|
- `normalizeENKeyword(tokenText) -> string`
|
||||||
|
- `matchENKeyword(tokenText, keyword) -> boolean`
|
||||||
|
|
||||||
|
## 验收标准(可验证)
|
||||||
|
|
||||||
|
- **索引语义一致**:
|
||||||
|
- EN:`"I am so tired"` tokens=`[I, am, so, tired]`,断点 `pos=2` 必然切为 `"I am"` / `"so tired"`
|
||||||
|
- TC:断点 `pos` 表示在第 `pos` 个 grapheme 之前断开
|
||||||
|
- **EN 标点极简派一致**:
|
||||||
|
- `"tired."` 作为一个 token;断点只允许在词与词之间
|
||||||
|
- **EN 关键词命中无误伤**:
|
||||||
|
- keyword=`"but"`:`"but,"` 命中;`"rebuttal"` 不命中
|
||||||
|
- **空白归一化确定性**:
|
||||||
|
- 输入含多空格/首尾空白时,输出 `normalizedText` 可预测且稳定
|
||||||
|
- **工具函数确定性**:相同输入在多次调用与多端实现中输出一致
|
||||||
|
|
||||||
|
## 依赖与关联
|
||||||
|
|
||||||
|
- **被依赖**:`breakpoint-candidates`、`scoring-tiebreak`、`search-engine-*`、`overflow-fallback`、`integration`
|
||||||
|
- **依赖**:TC token 分割依赖 `grapheme-segmentation`
|
||||||
|
|
||||||
150
spec_kit/Text Wrap/modules/core-contract/tasks.md
Normal file
150
spec_kit/Text Wrap/modules/core-contract/tasks.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# core-contract(任务清单)
|
||||||
|
|
||||||
|
> 对应计划:`spec_kit/Text Wrap/modules/core-contract/plan.md`
|
||||||
|
>
|
||||||
|
> 状态含义:`[ ]` 未完成,`[x]` 已完成。
|
||||||
|
> 执行完本清单后,需要在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充“已完成编码/任务执行完毕”的标记(见最后一节)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 任务标记规则
|
||||||
|
|
||||||
|
- 用勾选框标记执行状态:
|
||||||
|
- `[ ]` 未完成
|
||||||
|
- `[x]` 已完成
|
||||||
|
- 每个任务必须可独立验收(有明确产出与检查方式)。
|
||||||
|
- 涉及“口径”的任务,必须在代码注释中写清楚(简体中文),避免后续模块实现漂移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 文档对齐(先把口径写死,避免实现漂移)
|
||||||
|
|
||||||
|
- [x] 1.1 复核 `core-contract/spec.md` 与 `core-contract/plan.md` 的一致性
|
||||||
|
- **检查点**:
|
||||||
|
- `whitespacePolicy`(NORMALIZE/PRESERVE)语义一致
|
||||||
|
- EN token 规则为“极简派”(不拆标点、不生成 SPACE token)
|
||||||
|
- EN 关键词命中为“全词等值匹配”(禁止 substring)
|
||||||
|
- breaks 字典序比较规则清晰且无歧义
|
||||||
|
- **验收**:两份文档无冲突描述;关键字段命名一致。
|
||||||
|
|
||||||
|
- [x] 1.2 明确 `start/end` 的索引口径(以 normalizedText 为基准)并写入代码注释与 README(如有)
|
||||||
|
- **原因**:跨端 span/调试定位会依赖该口径
|
||||||
|
- **验收**:任意 token 的 `start/end` 都可映射到同一份文本基准(normalizedText)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目录与代码骨架(客户端侧优先落地)
|
||||||
|
|
||||||
|
> 说明:当前仓库已有 `client/src/features/*` 结构,Text Wrap 建议也放到 `features/` 下,便于后续 Home/Widget 复用。
|
||||||
|
|
||||||
|
- [x] 2.1 新建目录 `client/src/features/textWrap/core/`
|
||||||
|
- **产出**(建议文件):
|
||||||
|
- `types.ts`:Token/Config/Options 类型
|
||||||
|
- `normalizeWhitespace.ts`
|
||||||
|
- `tokenizeEN.ts`
|
||||||
|
- `joinTokens.ts`
|
||||||
|
- `enKeyword.ts`(normalizeENKeyword/matchENKeyword)
|
||||||
|
- `compare.ts`(breaks 字典序比较)
|
||||||
|
- `index.ts`(统一导出)
|
||||||
|
- **验收**:目录存在且可被 TS 正常 import(不报路径错误)。
|
||||||
|
|
||||||
|
- [x] 2.2 定义 `Token` 与基础配置类型(只含 core-contract 需要的字段)
|
||||||
|
- **要求**:
|
||||||
|
- `Token` 至少包含 `text/start/end`
|
||||||
|
- `CoreConfig` 至少包含 `whitespacePolicy` 与 `punctuationStripSetEN`
|
||||||
|
- **验收**:类型定义满足后续函数签名需要,且命名清晰。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心函数实现(纯函数 + 确定性)
|
||||||
|
|
||||||
|
- [x] 3.1 实现 `normalizeWhitespace(text)`(默认 NORMALIZE)
|
||||||
|
- **规则**:
|
||||||
|
- 连续空白折叠为单个空格
|
||||||
|
- 去除首尾空白
|
||||||
|
- 返回 `hadMultiWhitespace`(用于后续 meta 打点)
|
||||||
|
- **验收**:
|
||||||
|
- 输入 `" a b \n c "` 输出 `"a b c"`
|
||||||
|
- `hadMultiWhitespace` 在出现折叠/trim 时为 true
|
||||||
|
|
||||||
|
- [x] 3.2 实现 `tokenizeEN(normalizedText)`(极简派)
|
||||||
|
- **规则**:
|
||||||
|
- 以空格切分为 WORD tokens
|
||||||
|
- 标点作为 token.text 的一部分(不拆)
|
||||||
|
- 不生成 SPACE token
|
||||||
|
- **验收**:
|
||||||
|
- `"I am so tired"` → `[I, am, so, tired]`
|
||||||
|
- `"tired."` 为单 token
|
||||||
|
|
||||||
|
- [x] 3.3 实现 `joinTokens(tokens, start, end, separators?)`
|
||||||
|
- **规则**:
|
||||||
|
- 默认用单空格 join `[start..end)` 的 token.text
|
||||||
|
- `start/end` 为半开区间,越界/空区间需有明确行为(建议:空区间返回空字符串,交由上层硬约束处理)
|
||||||
|
- **验收**:
|
||||||
|
- tokens=`[I, am, so, tired]`,`join(0,2)` 为 `"I am"`
|
||||||
|
|
||||||
|
- [x] 3.4 实现 EN 关键词命中:`normalizeENKeyword` + `matchENKeyword`
|
||||||
|
- **规则**:
|
||||||
|
- `lowercase`
|
||||||
|
- strip 两端常见标点(使用配置 `punctuationStripSetEN`,全端一致)
|
||||||
|
- 等值比较(禁止 substring)
|
||||||
|
- **验收**:
|
||||||
|
- keyword=`but`:`but,` 命中;`rebuttal` 不命中
|
||||||
|
|
||||||
|
- [x] 3.5 实现 breaks 字典序比较 `compareBreaksLexicographically(a, b)`
|
||||||
|
- **规则**:
|
||||||
|
- 从 index=0 起逐项比较,首个不同元素更小者更小
|
||||||
|
- 公共前缀相同则更短数组更小
|
||||||
|
- **验收**:
|
||||||
|
- `[2] < [3]`
|
||||||
|
- `[2] < [2, 5]`
|
||||||
|
- `[2, 3] > [2]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 单元测试(Vitest,纯函数为主)
|
||||||
|
|
||||||
|
- [x] 4.1 新建测试目录 `client/src/features/textWrap/core/__tests__/`
|
||||||
|
- **验收**:测试文件可被现有 test runner 发现。
|
||||||
|
|
||||||
|
- [x] 4.2 为 `normalizeWhitespace` 增加用例
|
||||||
|
- **覆盖**:多空格、换行、首尾空白、空字符串、全空白字符串
|
||||||
|
- **验收**:测试断言输出字符串与 `hadMultiWhitespace` 符合预期。
|
||||||
|
|
||||||
|
- [x] 4.3 为 `tokenizeEN` + `joinTokens` 增加用例
|
||||||
|
- **覆盖**:普通句子、带标点的 token、单词间多个空格(先 normalize 再 tokenize)
|
||||||
|
- **验收**:tokens 序列与 join 后文本完全一致且确定。
|
||||||
|
|
||||||
|
- [x] 4.4 为 `matchENKeyword` 增加用例
|
||||||
|
- **覆盖**:大小写、两端标点、误伤样例(rebuttal vs but)
|
||||||
|
- **验收**:命中与不命中行为符合 spec。
|
||||||
|
|
||||||
|
- [x] 4.5 为 breaks 字典序比较增加用例
|
||||||
|
- **验收**:比较规则在多组数组上输出稳定顺序。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 最终自检清单(合入前)
|
||||||
|
|
||||||
|
- [x] 5.1 `tsc --noEmit` 通过(或项目既有 TS 检查命令通过)
|
||||||
|
- **验收**:无类型错误。
|
||||||
|
|
||||||
|
- [x] 5.2 `vitest` 通过(或项目既有测试命令通过)
|
||||||
|
- **验收**:新增用例全部通过,不影响现有测试。
|
||||||
|
|
||||||
|
- [x] 5.3 代码注释口径检查(简体中文)
|
||||||
|
- **检查点**:
|
||||||
|
- EN 极简派与“断点只在词间”
|
||||||
|
- 全词等值匹配(禁止 substring)
|
||||||
|
- `start/end` 基于 normalizedText 的口径说明
|
||||||
|
- **验收**:后续模块开发者只看代码也不会产生歧义。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 文档回写(任务清单执行完毕后必须做)
|
||||||
|
|
||||||
|
- [x] 6.1 在 `spec_kit/overview.md` 的 `Text Wrap` 条目下补充执行状态
|
||||||
|
- **建议写法**:
|
||||||
|
- 增加一行:`- **已完成编码(阶段性)**:core-contract(核心口径与契约)`
|
||||||
|
- **验收**:overview 能反映该子模块已完成,便于全局追踪。
|
||||||
|
|
||||||
57
spec_kit/Text Wrap/modules/golden-tests/plan.md
Normal file
57
spec_kit/Text Wrap/modules/golden-tests/plan.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# golden-tests(技术计划)
|
||||||
|
|
||||||
|
## 1. 计划目标
|
||||||
|
|
||||||
|
建立可持续回归体系,覆盖:
|
||||||
|
|
||||||
|
- **Golden Cases**:固定输入 → 固定输出(lines/wrappedText/meta),作为“可治理基线”
|
||||||
|
- **性质测试(property tests)**:
|
||||||
|
- 确定性:同输入多次调用输出一致
|
||||||
|
- 近似单调性:availableWidth 变小不会让任一行变得更宽(同测量口径下)
|
||||||
|
- maxLines 不变差:maxLines 增加时至少不从可解变 overflow
|
||||||
|
|
||||||
|
本模块产出测试数据与测试规则,不产出业务功能。
|
||||||
|
|
||||||
|
## 2. 约束与策略
|
||||||
|
|
||||||
|
### 2.1 测量可控
|
||||||
|
|
||||||
|
- APP:使用稳定的测量 mock(例如 `width = text.length`)保证 CI 可运行
|
||||||
|
- WIDGET:使用固定 profile 或 `widthMode=APPROX`(单位为 tokenCount/graphemeCount)
|
||||||
|
|
||||||
|
### 2.2 Golden 的组织方式
|
||||||
|
|
||||||
|
- 采用 TS fixture(便于类型校验与可读性)
|
||||||
|
- Golden case 至少包含:
|
||||||
|
- `text/lang/context/availableWidth/maxLines/overflowMode/lineMode/configVersion`
|
||||||
|
- 期望:`expected.lines/expected.wrappedText`(可选 meta 断言)
|
||||||
|
|
||||||
|
### 2.3 覆盖面(首版)
|
||||||
|
|
||||||
|
首版优先覆盖“易回归且高价值”的样例集:
|
||||||
|
|
||||||
|
- EN/TC 各若干条:短/长、含标点/无标点、含 emoji、含 shift/accum/self、极窄宽度
|
||||||
|
|
||||||
|
> 说明:文档建议每种语言 20 条;首版先落最小可运行集合,后续迭代扩充但保持可解释与版本化。
|
||||||
|
|
||||||
|
## 3. 测试实现结构(客户端)
|
||||||
|
|
||||||
|
- `client/src/features/textWrap/golden/fixtures.ts`(Golden cases)
|
||||||
|
- `client/src/features/textWrap/golden/__tests__/golden.test.ts`
|
||||||
|
- Golden 断言(lines/wrappedText)
|
||||||
|
- 性质测试(determinism/monotonic/maxLines)
|
||||||
|
|
||||||
|
测试内暂时使用“测试版 wrapTextHarness”把已实现模块串起来:
|
||||||
|
|
||||||
|
- normalize/tokenize(EN=tokenizeEN,TC=segmentGraphemes)
|
||||||
|
- generateBreakpoints
|
||||||
|
- search-engine-app / search-engine-widget
|
||||||
|
|
||||||
|
待 integration 模块产出正式 `wrapText()` 后,再把测试入口切到正式函数(不改变期望数据)。
|
||||||
|
|
||||||
|
## 4. 完成定义(DoD)
|
||||||
|
|
||||||
|
- Golden fixtures + 测试可在 CI 一键运行
|
||||||
|
- 至少包含 EN/TC 的基础样例与 3 类性质测试
|
||||||
|
- overview 更新记录变更文件
|
||||||
|
|
||||||
57
spec_kit/Text Wrap/modules/golden-tests/spec.md
Normal file
57
spec_kit/Text Wrap/modules/golden-tests/spec.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# golden-tests(子模块规范)
|
||||||
|
|
||||||
|
## 子模块名称
|
||||||
|
|
||||||
|
golden-tests(Golden Cases 与性质测试)
|
||||||
|
|
||||||
|
## 目标描述
|
||||||
|
|
||||||
|
建立可持续的回归体系,保证换行算法满足:
|
||||||
|
|
||||||
|
- 同输入同输出(确定性)
|
||||||
|
- 跨端一致(在固定测量/宽度 profile 下)
|
||||||
|
- 规则变更可控(通过 `configVersion` 回溯)
|
||||||
|
|
||||||
|
本模块产出的是测试数据与测试规则,不产出业务功能。
|
||||||
|
|
||||||
|
## 输入/输出定义
|
||||||
|
|
||||||
|
### 输入
|
||||||
|
|
||||||
|
- Golden Case 集合(建议 JSON/TS fixture):
|
||||||
|
- `text`
|
||||||
|
- `lang`
|
||||||
|
- `context`
|
||||||
|
- `availableWidth`
|
||||||
|
- `maxLines`
|
||||||
|
- `fontSpec?`(APP)
|
||||||
|
- `constraints?`
|
||||||
|
- `overflowMode?`
|
||||||
|
- `configVersion`
|
||||||
|
|
||||||
|
### 输出
|
||||||
|
|
||||||
|
- 对每个 case 的期望输出:
|
||||||
|
- `expected.lines: string[]`
|
||||||
|
- `expected.wrappedText: string`
|
||||||
|
- 可选:`expected.meta.breaks`、`expected.meta.fallback_type/overflow_type/reason`
|
||||||
|
|
||||||
|
并定义性质测试(property tests):
|
||||||
|
|
||||||
|
- **确定性**:同输入多次调用输出一致
|
||||||
|
- **近似单调性**:`availableWidth` 变小不会让任一行变得更宽(在同测量模式下)
|
||||||
|
- **maxLines 不变差**:`maxLines` 增加时至少不从可解变 overflow
|
||||||
|
|
||||||
|
## 验收标准(可验证)
|
||||||
|
|
||||||
|
- **覆盖度**:
|
||||||
|
- 每种语言至少 20 个样例:短/长、含标点/无标点、含 emoji、含 protectedPhrases、含 shift/accum/self、极窄宽度
|
||||||
|
- **固定口径**:
|
||||||
|
- 测量可控:APP 用稳定的测量 mock(或固定字体与平台);WIDGET 用固定 width profile
|
||||||
|
- **CI 可运行**:在自动化环境可一键跑完(不依赖人工操作)
|
||||||
|
|
||||||
|
## 依赖与关联
|
||||||
|
|
||||||
|
- **依赖**:`core-contract`、`search-engine-*`、`scoring-tiebreak`、`overflow-fallback`
|
||||||
|
- **被依赖**:`integration`(接入后验收也可引用 Golden)
|
||||||
|
|
||||||
50
spec_kit/Text Wrap/modules/golden-tests/tasks.md
Normal file
50
spec_kit/Text Wrap/modules/golden-tests/tasks.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# golden-tests(任务清单)
|
||||||
|
|
||||||
|
> 目标:建立 Golden Cases 与性质测试(determinism/monotonic/maxLines)回归体系,保证算法演进可控。
|
||||||
|
|
||||||
|
## 0. 对齐与准备
|
||||||
|
|
||||||
|
- [x] 阅读并对齐口径
|
||||||
|
- [x] 阅读 `spec_kit/Text Wrap/modules/golden-tests/spec.md`
|
||||||
|
- [x] 阅读 `spec_kit/Text Wrap/modules/golden-tests/plan.md`
|
||||||
|
- [x] 阅读 `设计说明文档/文档换行算法.md` 的 16.1/16.2 章节
|
||||||
|
- [x] 创建客户端测试目录
|
||||||
|
- [x] 新建 `client/src/features/textWrap/golden/`
|
||||||
|
|
||||||
|
## 1. Golden fixtures
|
||||||
|
|
||||||
|
- [x] 新建 `client/src/features/textWrap/golden/fixtures.ts`
|
||||||
|
- [x] 定义 `GoldenCase` 类型(text/lang/context/availableWidth/maxLines 等)
|
||||||
|
- [x] 补充 EN/TC 基础样例集合(首版最小可运行)
|
||||||
|
- [x] 对每个 case 写入 `expected.lines/expected.wrappedText`
|
||||||
|
|
||||||
|
## 2. 测试 harness(临时串联)
|
||||||
|
|
||||||
|
- [x] 在测试中实现 `wrapTextHarness()`(仅用于测试)
|
||||||
|
- [x] normalizeWhitespace
|
||||||
|
- [x] EN tokenizeEN / TC segmentGraphemes
|
||||||
|
- [x] 使用“全断点集合”(1..N-1,首版避免候选裁剪影响)
|
||||||
|
- [x] APP:searchBestLayoutApp(测量 mock=length)
|
||||||
|
- [x] WIDGET:searchBestLayoutWidget(widthMode=APPROX,单位一致)
|
||||||
|
|
||||||
|
## 3. Golden cases 测试
|
||||||
|
|
||||||
|
- [x] 新建 `client/src/features/textWrap/golden/__tests__/golden.test.ts`
|
||||||
|
- [x] 遍历 fixtures,断言输出与 expected 完全一致
|
||||||
|
|
||||||
|
## 4. 性质测试(property tests)
|
||||||
|
|
||||||
|
- [x] 确定性:同输入运行多次输出一致
|
||||||
|
- [x] 近似单调性:availableWidth 变小不会让任一行变得更宽(同测量 mock 下,用 width=string.length)
|
||||||
|
- [x] maxLines 不变差:maxLines 增大时至少不从可解变 overflow(同测量 mock 下)
|
||||||
|
|
||||||
|
## 5. 收尾
|
||||||
|
|
||||||
|
- [x] 跑测试与类型检查
|
||||||
|
- [x] `npm test`
|
||||||
|
- [x] `npx tsc --noEmit`
|
||||||
|
- [x] 将本 `tasks.md` 全部勾选完成
|
||||||
|
- [x] 更新 `spec_kit/overview.md`
|
||||||
|
- [x] 标记 `golden-tests` 已完成(阶段性)
|
||||||
|
- [x] 写入变更文件清单
|
||||||
|
|
||||||
136
spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md
Normal file
136
spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# grapheme-segmentation(技术计划)
|
||||||
|
|
||||||
|
## 1. 计划目标
|
||||||
|
|
||||||
|
基于 `spec.md` 与 `设计说明文档/文档换行算法.md v1.2.1`,在客户端侧(JS/TS)落地**可复用且确定性**的 TC grapheme cluster(字符簇)分割能力,用于 Text Wrap 的 TC tokens 生成,确保:
|
||||||
|
|
||||||
|
- 不拆 surrogate pair、ZWJ、VS16、肤色修饰符、国旗(regional indicator flags)、组合字符(如 `é`)
|
||||||
|
- 同输入同输出(clusters 内容与顺序完全一致)
|
||||||
|
- 输出边界可作为 TC 断点边界(后续模块仅在 cluster 边界断行)
|
||||||
|
- 提供可解释 meta(采用何种策略、是否降级)
|
||||||
|
|
||||||
|
## 2. 默认技术决策(本计划采用)
|
||||||
|
|
||||||
|
> 说明:本模块要解决的是“分割口径”,不引入排版/搜索逻辑。
|
||||||
|
|
||||||
|
- **优先策略**:若运行环境支持 `Intl.Segmenter`,优先使用:
|
||||||
|
- `new Intl.Segmenter('zh-Hant', { granularity: 'grapheme' })`
|
||||||
|
- 取其 `segment(text)` 的 `segment` 字段作为 clusters
|
||||||
|
- **兜底策略(两种实现路径,默认选 A)**:
|
||||||
|
- **方案 A(推荐)**:引入轻量依赖 `grapheme-splitter` 作为 fallback,避免手写不完整的 Unicode 规则导致漏拆/误拆
|
||||||
|
- **方案 B(无依赖兜底)**:实现“最低可用”分割(满足文档列出的组合不拆),但不承诺覆盖所有 Unicode 边界规则(风险较高)
|
||||||
|
|
||||||
|
> 本计划默认采用 **方案 A**。若后续明确“禁止新增依赖”,再切换到方案 B 并补齐更多回归。
|
||||||
|
|
||||||
|
## 3. 目录与产物
|
||||||
|
|
||||||
|
本子模块目录:
|
||||||
|
|
||||||
|
- `spec_kit/Text Wrap/modules/grapheme-segmentation/spec.md`
|
||||||
|
- `spec_kit/Text Wrap/modules/grapheme-segmentation/plan.md`(本文)
|
||||||
|
|
||||||
|
建议未来代码落位(实现阶段落地):
|
||||||
|
|
||||||
|
- `client/src/features/textWrap/grapheme/`
|
||||||
|
- `segmentGraphemes.ts`
|
||||||
|
- `strategies/intlSegmenter.ts`
|
||||||
|
- `strategies/fallback.ts`
|
||||||
|
- `__tests__/segmentGraphemes.test.ts`
|
||||||
|
|
||||||
|
## 4. API 设计(实现阶段的稳定契约)
|
||||||
|
|
||||||
|
实现一个最小可用纯函数:
|
||||||
|
|
||||||
|
- `segmentGraphemes(text: string, mode: 'PREFERRED' | 'FALLBACK') => { clusters: string[]; meta: { strategy: 'INTL_SEGMENTER' | 'FALLBACK'; hadFallback: boolean } }`
|
||||||
|
|
||||||
|
设计约束:
|
||||||
|
|
||||||
|
- `clusters.join('') === text`(不允许丢字符/改字符顺序)
|
||||||
|
- `clusters` 为空数组时必须是 `text==''`(不允许把空白当成 cluster 误输出)
|
||||||
|
|
||||||
|
## 5. 实现步骤(按落地顺序)
|
||||||
|
|
||||||
|
### 5.1 优先策略:Intl.Segmenter
|
||||||
|
|
||||||
|
- 检测 `globalThis.Intl?.Segmenter` 是否可用
|
||||||
|
- 若可用且 `mode='PREFERRED'`:
|
||||||
|
- 使用 `granularity='grapheme'` 分割
|
||||||
|
- 输出 `meta.strategy='INTL_SEGMENTER'`,`hadFallback=false`
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 需要确认 Expo/RN 的运行时是否始终具备 `Intl.Segmenter`(不同 JS 引擎/版本可能差异)
|
||||||
|
- 即使可用,也必须通过回归样例验证“不拆”要求
|
||||||
|
|
||||||
|
### 5.2 兜底策略:Fallback
|
||||||
|
|
||||||
|
当出现以下任一情况时进入 fallback:
|
||||||
|
|
||||||
|
- `mode='FALLBACK'`
|
||||||
|
- `Intl.Segmenter` 不存在
|
||||||
|
- `Intl.Segmenter` 运行抛错/返回异常结果(如空、丢字符)
|
||||||
|
|
||||||
|
#### 方案 A:`grapheme-splitter`
|
||||||
|
|
||||||
|
- 新增依赖:`grapheme-splitter`
|
||||||
|
- 使用其分割能力输出 clusters
|
||||||
|
- 输出 `meta.strategy='FALLBACK'`,`hadFallback=true`
|
||||||
|
|
||||||
|
#### 方案 B:最低可用手写规则(仅在禁止依赖时启用)
|
||||||
|
|
||||||
|
实现最低要求:
|
||||||
|
|
||||||
|
- 合并 surrogate pair
|
||||||
|
- 合并 ZWJ sequence(`U+200D` 连接)
|
||||||
|
- 合并 variation selector(如 `U+FE0F`)
|
||||||
|
- 合并 skin tone modifier(`U+1F3FB..U+1F3FF`)
|
||||||
|
- 合并 regional indicator flags(两两成对)
|
||||||
|
- 合并组合字符(combining marks)与预组合等价形式(至少覆盖 `e\u0301`)
|
||||||
|
|
||||||
|
风险提示:
|
||||||
|
|
||||||
|
- 该实现容易漏掉其他扩展 grapheme cluster 规则;需要更高的测试覆盖与持续维护
|
||||||
|
|
||||||
|
## 6. 回归用例与测试计划(Vitest)
|
||||||
|
|
||||||
|
### 6.1 必须覆盖的样例(文档要求)
|
||||||
|
|
||||||
|
以下输入必须“不拆”为单个 cluster:
|
||||||
|
|
||||||
|
- `👨👩👧👦`
|
||||||
|
- `🇸🇬`
|
||||||
|
- `👍🏽`
|
||||||
|
- `😮💨`
|
||||||
|
- `é`(至少覆盖 `e\u0301` 组合形式)
|
||||||
|
|
||||||
|
断言:
|
||||||
|
|
||||||
|
- `clusters.length === 1`
|
||||||
|
- `clusters[0] === input`
|
||||||
|
|
||||||
|
### 6.2 基础性质测试(建议)
|
||||||
|
|
||||||
|
- **可逆性**:`clusters.join('') === input`
|
||||||
|
- **确定性**:同输入多次调用输出完全一致
|
||||||
|
- **空字符串**:`'' -> []`
|
||||||
|
|
||||||
|
### 6.3 跨策略一致性(建议)
|
||||||
|
|
||||||
|
在支持 `Intl.Segmenter` 的环境中:
|
||||||
|
|
||||||
|
- 同一输入在 `PREFERRED` 与 `FALLBACK` 两种模式下输出 clusters 应一致
|
||||||
|
- 若出现差异,必须新增回归样例并明确差异原因(并在上层通过 configVersion 治理)
|
||||||
|
|
||||||
|
## 7. 性能与安全
|
||||||
|
|
||||||
|
- 单次分割复杂度应接近 \(O(n)\)
|
||||||
|
- 对超长文本(例如 > 2000 code units)建议在上层模块触发裁剪或打点(本模块仅保证不崩溃)
|
||||||
|
- 任何异常必须被捕获并降级到 fallback(保证“可用性优先”)
|
||||||
|
|
||||||
|
## 8. 完成定义(DoD)
|
||||||
|
|
||||||
|
- `segmentGraphemes()` 在本地可运行,并通过必测回归样例
|
||||||
|
- 输出 meta 能区分 `INTL_SEGMENTER` 与 `FALLBACK`
|
||||||
|
- 单测覆盖:必测样例 + 可逆性 + 确定性
|
||||||
|
- 文档口径与实现一致(不拆要求、fallback 触发条件、返回结构)
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user