完善UI编辑器字体预览与BestFit
绑定字体加载成功后应用私有 font-family,并回退系统字体 新增字形映射和浏览器真实文本测量的 BestFit 算法 补充文本渲染回归测试及字体跨运行时一致性待评估文档 更新UI编辑器字体预览权威实施合同
This commit is contained in:
@@ -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}
|
||||
|
||||
+47
-14
@@ -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>;
|
||||
};
|
||||
|
||||
+81
@@ -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 契约
|
||||
|
||||
|
||||
Reference in New Issue
Block a user