diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts index 761988254..92ac8d202 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts @@ -1,7 +1,6 @@ import type { CSSProperties } from 'react'; import type { FillMethod } from '../types/FillMethod'; -import type { FontSizing } from '../types/FontSizing'; import type { HorizontalTextOverflow } from '../types/HorizontalTextOverflow'; import type { ImageComponent } from '../types/ImageComponent'; import type { ImageType } from '../types/ImageType'; @@ -9,9 +8,13 @@ import type { SpriteAsset } from '../types/SpriteAsset'; import type { TextAlignment } from '../types/TextAlignment'; import type { TextComponent } from '../types/TextComponent'; import type { VerticalTextOverflow } from '../types/VerticalTextOverflow'; +import { + fontStyleToCss, + initialFontSize, + SYSTEM_UI_FONT_STACK, +} from './textStyleToCss'; -export const SYSTEM_UI_FONT_STACK = - 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; +export { SYSTEM_UI_FONT_STACK } from './textStyleToCss'; export type ImageRenderModel = { kind: 'image' | 'background' | 'filled'; @@ -208,12 +211,6 @@ export function imageComponentToRenderModel( } } -function fontSize(sizing: FontSizing): number { - if ('Fixed' in sizing) return Math.max(1, finite(sizing.Fixed, 'font size')); - // TODO: implement measured BestFit using node dimensions and text metrics. - return Math.max(1, finite(sizing.BestFit.max, 'BestFit.max')); -} - function alignment( alignmentValue: TextAlignment, ): Pick { @@ -252,9 +249,8 @@ export function textComponentToCss(component: TextComponent): CSSProperties { padding: 0, color: `rgba(${red}, ${green}, ${blue}, ${Math.min(255, Math.max(0, alpha)) / 255})`, fontFamily: SYSTEM_UI_FONT_STACK, - fontSize: `${fontSize(component.font_sizing)}px`, - fontStyle: 'normal', - fontWeight: 400, + fontSize: `${finite(initialFontSize(component.font_sizing), 'font size')}px`, + ...fontStyleToCss(component.font_style), ...alignment(component.alignment), lineHeight: finite(component.line_spacing, 'line_spacing'), whiteSpace: horizontalOverflow === 'Wrap' ? 'normal' : 'nowrap', diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/textStyleToCss.ts b/apps/ai-game-creator-shell/src/features/ui-editor/utils/textStyleToCss.ts new file mode 100644 index 000000000..769fabfdf --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/textStyleToCss.ts @@ -0,0 +1,59 @@ +import type { CSSProperties } from 'react'; + +import type { FontSizing } from '../types/FontSizing'; +import type { FontSource } from '../types/FontSource'; +import type { FontStyle } from '../types/FontStyle'; +import type { UiEditorFontFaceState } from '../useUiEditorFontFaces'; + +export const SYSTEM_UI_FONT_STACK = + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + +export function fontStyleToCss( + fontStyle: FontStyle, +): Pick { + switch (fontStyle) { + case 'Bold': + return { fontStyle: 'normal', fontWeight: 700 }; + case 'Italic': + return { fontStyle: 'italic', fontWeight: 400 }; + case 'BoldItalic': + return { fontStyle: 'italic', fontWeight: 700 }; + case 'Normal': + return { fontStyle: 'normal', fontWeight: 400 }; + } +} + +export function fontFamilyToCss( + source: FontSource, + fontFaces: Record, +): string { + if (typeof source === 'string') return SYSTEM_UI_FONT_STACK; + const fontFace = fontFaces[source.Bound]; + return fontFace?.status === 'loaded' + ? fontFace.cssFamily + : SYSTEM_UI_FONT_STACK; +} + +export function initialFontSize(sizing: FontSizing): number { + return 'Fixed' in sizing ? sizing.Fixed : sizing.BestFit.min; +} + +export function findLargestFittingFontSize( + min: number, + max: number, + fits: (size: number) => boolean, +): number { + let lower = min; + let upper = max; + let best = min; + while (lower <= upper) { + const candidate = Math.floor((lower + upper) / 2); + if (fits(candidate)) { + best = candidate; + lower = candidate + 1; + } else { + upper = candidate - 1; + } + } + return best; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index 3aa5058ca..fa405eaa0 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -405,7 +405,6 @@ export function PreviewWorkspace({ resources={{ previewUrls, sprites: controller.sprites, - fonts: editor.state.font_assets, fontFaces: controller.fontFaces, }} onSelectNode={controller.selectNode} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/TextComponentView.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/TextComponentView.tsx index 6c94d1cde..ef5e97128 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/TextComponentView.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/TextComponentView.tsx @@ -1,7 +1,14 @@ +import { useMemo, useRef } from 'react'; + import type { TextComponent } from '../../../../../features/ui-editor/types/TextComponent'; import { textComponentToCss } from '../../../../../features/ui-editor/utils/componentToCss'; +import { fontFamilyToCss } from '../../../../../features/ui-editor/utils/textStyleToCss'; import { MissingComponent } from './MissingComponent'; import type { PreviewComponentResources } from './types'; +import { + bestFitMeasurementStyle, + useBestFitFontSize, +} from './useBestFitFontSize'; export function TextComponentView({ component, @@ -10,22 +17,48 @@ export function TextComponentView({ component: TextComponent; resources: PreviewComponentResources; }) { - // TODO: FontAsset currently has no browser URL; keep the resource lookup - // explicit so a future @font-face implementation has one integration point. - if (typeof component.font !== 'string') - void resources.fonts[component.font.Bound]; - - try { - return ( + const containerRef = useRef(null); + const measurementRef = useRef(null); + const fontFamily = fontFamilyToCss(component.font, resources.fontFaces); + const initialCss = useMemo(() => { + try { + return { ...textComponentToCss(component), fontFamily }; + } catch { + return null; + } + }, [component, fontFamily]); + const measurementKey = [ + component.content, + fontFamily, + component.font_style, + component.line_spacing, + component.horizontal_overflow, + component.vertical_overflow, + ].join('\u0000'); + const fontSize = useBestFitFontSize({ + sizing: component.font_sizing, + containerRef, + measurementRef, + measurementKey, + }); + if (!initialCss) return ; + const textCss = { ...initialCss, fontSize: `${fontSize}px` }; + return ( +
+ {component.content} - ); - } catch { - return ; - } +
+ ); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/types.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/types.ts index 6ffa89f70..0d39a84cf 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/types.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/types.ts @@ -1,10 +1,8 @@ -import type { FontAsset } from '../../../../../features/ui-editor/types/FontAsset'; import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset'; import type { UiEditorFontFaceState } from '../../../../../features/ui-editor/useUiEditorFontFaces'; export type PreviewComponentResources = { previewUrls: Record; sprites: Record; - fonts: Record; fontFaces: Record; }; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/useBestFitFontSize.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/useBestFitFontSize.ts new file mode 100644 index 000000000..f318c1b1d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/useBestFitFontSize.ts @@ -0,0 +1,81 @@ +import type { CSSProperties, RefObject } from 'react'; +import { useLayoutEffect, useState } from 'react'; + +import type { FontSizing } from '../../../../../features/ui-editor/types/FontSizing'; +import { + findLargestFittingFontSize, + initialFontSize, +} from '../../../../../features/ui-editor/utils/textStyleToCss'; + +function isPositiveFinite(value: number): boolean { + return Number.isFinite(value) && value > 0; +} + +export function useBestFitFontSize({ + sizing, + containerRef, + measurementRef, + measurementKey, +}: { + sizing: FontSizing; + containerRef: RefObject; + measurementRef: RefObject; + measurementKey: string; +}): number { + const initialSize = initialFontSize(sizing); + const [fontSize, setFontSize] = useState(initialSize); + + useLayoutEffect(() => { + if ('Fixed' in sizing) { + setFontSize((current) => + current === sizing.Fixed ? current : sizing.Fixed, + ); + return; + } + + const container = containerRef.current; + const measurement = measurementRef.current; + if (!container || !measurement) return; + const { min, max } = sizing.BestFit; + const measure = () => { + const width = container.clientWidth; + const height = container.clientHeight; + if (!isPositiveFinite(width) || !isPositiveFinite(height)) { + setFontSize((current) => (current === min ? current : min)); + return; + } + const next = findLargestFittingFontSize(min, max, (size) => { + measurement.style.fontSize = `${size}px`; + return ( + measurement.scrollWidth <= width && measurement.scrollHeight <= height + ); + }); + setFontSize((current) => (current === next ? current : next)); + }; + + measure(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + } + const observer = new ResizeObserver(measure); + observer.observe(container); + return () => observer.disconnect(); + }, [containerRef, initialSize, measurementKey, measurementRef, sizing]); + + return fontSize; +} + +export const bestFitMeasurementStyle: CSSProperties = { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 'auto', + width: '100%', + height: 'auto', + boxSizing: 'border-box', + visibility: 'hidden', + pointerEvents: 'none', + overflow: 'visible', +}; diff --git a/apps/ai-game-creator-shell/tests/uiEditorTextRendering.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorTextRendering.test.tsx new file mode 100644 index 000000000..f66f44377 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorTextRendering.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment jsdom + +import { act, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { TextComponent } from '../src/features/ui-editor/types/TextComponent'; +import { + findLargestFittingFontSize, + fontFamilyToCss, + fontStyleToCss, + SYSTEM_UI_FONT_STACK, +} from '../src/features/ui-editor/utils/textStyleToCss'; +import { TextComponentView } from '../src/view/ui-editor/components/preview/components/TextComponentView'; + +function text(overrides: Partial = {}): TextComponent { + return { + content: '测试文本', + font: 'SystemFont', + font_style: 'Normal', + font_sizing: { Fixed: 14 }, + color: [0, 0, 0, 255], + alignment: 'UpperLeft', + horizontal_overflow: 'Wrap', + vertical_overflow: 'Truncate', + line_spacing: 1, + ...overrides, + }; +} + +const resources = { + previewUrls: {}, + sprites: {}, + fontFaces: {}, +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('UI 编辑器文本样式算法', () => { + it.each([ + ['Normal', { fontStyle: 'normal', fontWeight: 400 }], + ['Bold', { fontStyle: 'normal', fontWeight: 700 }], + ['Italic', { fontStyle: 'italic', fontWeight: 400 }], + ['BoldItalic', { fontStyle: 'italic', fontWeight: 700 }], + ] as const)('maps %s to browser font properties', (style, expected) => { + expect(fontStyleToCss(style)).toEqual(expected); + }); + + it('uses a loaded bound font and falls back for all other states', () => { + expect( + fontFamilyToCss( + { Bound: 'body' }, + { + body: { cssFamily: 'ui-editor-font-body', status: 'loaded' }, + }, + ), + ).toBe('ui-editor-font-body'); + for (const status of ['loading', 'error'] as const) { + expect( + fontFamilyToCss( + { Bound: 'body' }, + { + body: { cssFamily: 'ui-editor-font-body', status }, + }, + ), + ).toBe(SYSTEM_UI_FONT_STACK); + } + expect(fontFamilyToCss({ Bound: 'missing' }, {})).toBe( + SYSTEM_UI_FONT_STACK, + ); + }); + + it('finds the greatest fitting integer and retains min if none fits', () => { + expect(findLargestFittingFontSize(8, 20, (size) => size <= 15)).toBe(15); + expect(findLargestFittingFontSize(8, 20, () => false)).toBe(8); + }); +}); + +describe('TextComponentView', () => { + it('applies a loaded bound font and font style to the preview', () => { + render( + , + ); + const view = document.querySelector( + '[data-component-kind="Text"]', + ) as HTMLDivElement; + expect(view.style.fontFamily).toBe('ui-editor-font-body'); + expect(view.style.fontStyle).toBe('italic'); + expect(view.style.fontWeight).toBe('700'); + }); + + it('recalculates BestFit after its container is measured and resized', () => { + let observerCallback: ResizeObserverCallback | undefined; + class TestResizeObserver { + constructor(callback: ResizeObserverCallback) { + observerCallback = callback; + } + observe = vi.fn(); + disconnect = vi.fn(); + } + vi.stubGlobal('ResizeObserver', TestResizeObserver); + render( + , + ); + const view = document.querySelector( + '[data-component-kind="Text"]', + ) as HTMLDivElement; + const measurement = document.querySelector( + '[data-ui-editor-best-fit-measurement="true"]', + ) as HTMLDivElement; + let width = 120; + Object.defineProperty(view, 'clientWidth', { + configurable: true, + get: () => width, + }); + Object.defineProperty(view, 'clientHeight', { + configurable: true, + get: () => 24, + }); + Object.defineProperty(measurement, 'scrollWidth', { + configurable: true, + get: () => Number.parseInt(measurement.style.fontSize, 10) * 8, + }); + Object.defineProperty(measurement, 'scrollHeight', { + configurable: true, + get: () => 20, + }); + + act(() => observerCallback?.([], {} as ResizeObserver)); + expect(view.style.fontSize).toBe('15px'); + width = 72; + act(() => observerCallback?.([], {} as ResizeObserver)); + expect(view.style.fontSize).toBe('9px'); + }); +}); diff --git a/docs/project-memory/todos/【待评估】UI编辑器字体跨运行时像素一致性-2026-08-19.md b/docs/project-memory/todos/【待评估】UI编辑器字体跨运行时像素一致性-2026-08-19.md new file mode 100644 index 000000000..8b93310c5 --- /dev/null +++ b/docs/project-memory/todos/【待评估】UI编辑器字体跨运行时像素一致性-2026-08-19.md @@ -0,0 +1,11 @@ +# UI 编辑器字体跨运行时像素一致性评估 + +## 背景 + +UI Editor 预览已使用浏览器真实排版测量 `BestFit`:同样式的不可见文本节点按容器实际尺寸,在整数 `[min,max]` 中选择最大可完整容纳字号。绑定字体只在 `FontFace` 加载成功后生效,加载中、失败或悬空引用回退系统字体。 + +## 待评估范围 + +- 比较目标浏览器 WebView 与后续正式运行时的字体回退、字形替换、换行和行高是否需要像素级一致。 +- 如需一致,先确认权威运行时、目标平台与可接受误差,再设计可复现的字体夹具和视觉回归门禁。 +- 不得把此评估误解为 `BestFit` 尚未实现,或重新引入仅取 `BestFit.max` 的近似逻辑。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a51988c73..e9336e086 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -24,11 +24,11 @@ UI Editor Inspector 的全局只读状态唯一来源是 `controller.editor.isLo ## 2026-08-17 UI Editor 项目字体导入与预览 -UI Editor 的 `State.font_assets` 正式承载项目字体面资源;`FontAsset` 包含项目资产 ID、受控项目相对路径、内容 SHA-256,以及由 Rust 解析的 family、face、weight、italic、格式和源文件名。`TextComponent.font` 直接绑定一个具体字体面;原 `font_style` 是与字体面元数据重复且可能矛盾的状态,直接从 Rust 权威类型和生成 TypeScript 类型中删除,不保留会话态兼容层。UI Editor 整体 State 仍只属于当前桌面会话,不新增持久化合同。 +UI Editor 的 `State.font_assets` 正式承载项目字体面资源;`FontAsset` 包含项目资产 ID、受控项目相对路径、内容 SHA-256,以及由 Rust 解析的 family、face、weight、italic、格式和源文件名。`TextComponent.font` 直接绑定一个具体字体面,`font_style` 继续表达组件要求的 Normal / Bold / Italic / BoldItalic 浏览器字形;预览将其稳定映射为 CSS `fontWeight` 与 `fontStyle`。UI Editor 整体 State 仍只属于当前桌面会话,不新增持久化合同。 字体资源发现与 Sprite 保持同一项目资产语义:两种 importer 在 FileManager Home 下都从唯一的 `本地项目` 根进入,再按各自 `typeFilter` 展示已登记资源;不扫描或接纳未登记文件,字体不展示云端素材库。从电脑导入使用 Tauri 系统文件选择器,Rust 整批读取普通文件,拒绝符号链接、集合字体、超过 `8 MiB` 的单文件、超过 `64` 个字体面或 `32 MiB` 项目总量,完成真实签名、字体表、名称与 weight/style 解析后复制到 `assets/fonts/` 并登记 manifest。内容相同的字体复用已登记资源;Sprite 与 Font 的 State 批量加入都采用幂等合并:相同 ID 且完整资源相等时跳过,同 ID 数据冲突时整批失败。删除只移除会话 State 资源并清空相应 `Image.target_graphic` 或 `Text.font` 引用,不删除项目文件或 manifest 条目。 -候选格式为 TTF、OTF、WOFF 和 WOFF2,但 Rust 安全解析是导入硬门;当前解析依赖不能完整解析的压缩 Web Font 必须拒绝,不能把浏览器可能加载当作验证成功。已登记字体字节只能经字体专用 Tauri 命令读取;命令重新核对 manifest 的 asset ID / 相对路径、普通文件、大小、字体结构与摘要。Importer 预览也只读取同一受 manifest 约束的字体字节,再临时加载 `FontFace` 显示黑色的中英文、数字和标点多字号样张;切换选择或关闭时立即卸载。前端以 `FontAssetId` 派生私有 CSS family,创建 Blob URL 和 `FontFace`,加载成功后加入当前 `document.fonts`,资源变更或卸载时删除 FontFace 并回收 Blob URL。同名 family 不共享浏览器注册名。WebView 加载失败或字体缺少当前文本字形时不阻塞后续阶段,Inspector 显示非阻断提示并回退系统字体;悬空字体 ID 继续由 prerequisite 阻止。`BestFit` 保持现有取最大字号的近似,不在本次字体闭环中扩展为测量算法。 +候选格式为 TTF、OTF、WOFF 和 WOFF2,但 Rust 安全解析是导入硬门;当前解析依赖不能完整解析的压缩 Web Font 必须拒绝,不能把浏览器可能加载当作验证成功。已登记字体字节只能经字体专用 Tauri 命令读取;命令重新核对 manifest 的 asset ID / 相对路径、普通文件、大小、字体结构与摘要。Importer 预览也只读取同一受 manifest 约束的字体字节,再临时加载 `FontFace` 显示黑色的中英文、数字和标点多字号样张;切换选择或关闭时立即卸载。前端以 `FontAssetId` 派生私有 CSS family,创建 Blob URL 和 `FontFace`,加载成功后加入当前 `document.fonts`,资源变更或卸载时删除 FontFace 并回收 Blob URL。同名 family 不共享浏览器注册名。预览仅在绑定字体已加载时使用其私有 family;加载中、失败或悬空引用均回退系统字体。`BestFit` 使用同样式的不可见浏览器文本节点和容器实际尺寸,在整数 `[min,max]` 中二分取得最大可完整容纳字号;没有字号能完整容纳时使用 `min`,容器、文本、字体族、字形、行高或溢出规则变化后重新测量。WebView 加载失败或字体缺少当前文本字形时不阻塞后续阶段,Inspector 显示非阻断提示并回退系统字体;悬空字体 ID 继续由 prerequisite 阻止。 ## 2026-08-18 UI Editor 图片 / 字体通用 AssetImporter 契约