完善UI编辑器字体预览与BestFit

绑定字体加载成功后应用私有 font-family,并回退系统字体

新增字形映射和浏览器真实文本测量的 BestFit 算法

补充文本渲染回归测试及字体跨运行时一致性待评估文档

更新UI编辑器字体预览权威实施合同
This commit is contained in:
2026-08-19 13:56:08 +08:00
parent b5351f03fa
commit 17b09804fa
9 changed files with 358 additions and 31 deletions
@@ -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<CSSProperties, 'alignItems' | 'justifyContent' | 'textAlign'> {
@@ -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',
@@ -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<CSSProperties, 'fontStyle' | 'fontWeight'> {
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, UiEditorFontFaceState>,
): 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;
}
@@ -405,7 +405,6 @@ export function PreviewWorkspace({
resources={{
previewUrls,
sprites: controller.sprites,
fonts: editor.state.font_assets,
fontFaces: controller.fontFaces,
}}
onSelectNode={controller.selectNode}
@@ -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<HTMLDivElement>(null);
const measurementRef = useRef<HTMLDivElement>(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 <MissingComponent message="Text 参数无效" />;
const textCss = { ...initialCss, fontSize: `${fontSize}px` };
return (
<div
ref={containerRef}
className="pointer-events-none absolute inset-0"
style={textCss}
data-component-kind="Text"
>
{component.content}
<div
className="pointer-events-none absolute inset-0"
style={textComponentToCss(component)}
data-component-kind="Text"
ref={measurementRef}
aria-hidden="true"
data-ui-editor-best-fit-measurement="true"
style={{ ...textCss, ...bestFitMeasurementStyle }}
>
{component.content}
</div>
);
} catch {
return <MissingComponent message="Text 参数无效" />;
}
</div>
);
}
@@ -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<string, string>;
sprites: Record<string, SpriteAsset>;
fonts: Record<string, FontAsset>;
fontFaces: Record<string, UiEditorFontFaceState>;
};
@@ -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<HTMLElement | null>;
measurementRef: RefObject<HTMLElement | null>;
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',
};
@@ -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> = {}): 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(
<TextComponentView
component={text({
font: { Bound: 'body' },
font_style: 'BoldItalic',
})}
resources={{
...resources,
fontFaces: {
body: { cssFamily: 'ui-editor-font-body', status: 'loaded' },
},
}}
/>,
);
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(
<TextComponentView
component={text({ font_sizing: { BestFit: { min: 8, max: 20 } } })}
resources={resources}
/>,
);
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');
});
});
@@ -0,0 +1,11 @@
# UI 编辑器字体跨运行时像素一致性评估
## 背景
UI Editor 预览已使用浏览器真实排版测量 `BestFit`:同样式的不可见文本节点按容器实际尺寸,在整数 `[min,max]` 中选择最大可完整容纳字号。绑定字体只在 `FontFace` 加载成功后生效,加载中、失败或悬空引用回退系统字体。
## 待评估范围
- 比较目标浏览器 WebView 与后续正式运行时的字体回退、字形替换、换行和行高是否需要像素级一致。
- 如需一致,先确认权威运行时、目标平台与可接受误差,再设计可复现的字体夹具和视觉回归门禁。
- 不得把此评估误解为 `BestFit` 尚未实现,或重新引入仅取 `BestFit.max` 的近似逻辑。
@@ -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 契约