修复导入器字体预览与根目录

统一图片与字体导入器的本地项目根目录

将导入器预览作为 settings 组件传入,导入器不判断资产类型

复用 Inspector 字体加载链路和精灵图片呈现

在字体预览中展示黑色多字号中英文数字标点样张
This commit is contained in:
2026-08-18 19:20:06 +08:00
parent 98ff635db3
commit 892c515d09
12 changed files with 292 additions and 191 deletions
@@ -1996,19 +1996,6 @@ pub(crate) fn read_ui_editor_font_bytes(
.map(|(_, bytes)| tauri::ipc::Response::new(bytes))
}
#[tauri::command]
pub(crate) fn read_ui_editor_font_preview(
project_path: String,
asset_id: String,
relative_path: String,
) -> Result<tauri::ipc::Response, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "file.read")?;
let manifest = read_existing_manifest_for_project(root)?;
let asset = find_registered_ui_editor_font(&manifest, asset_id.trim(), relative_path.trim())?;
read_registered_ui_editor_font(root, asset).map(|(_, bytes)| tauri::ipc::Response::new(bytes))
}
#[tauri::command]
pub(crate) fn check_ui_editor_font_glyph_coverage(
project_path: String,
@@ -2301,7 +2301,6 @@ fn main() {
import_ui_editor_assets,
prepare_ui_editor_project_fonts,
read_ui_editor_font_bytes,
read_ui_editor_font_preview,
check_ui_editor_font_glyph_coverage,
suggest_ui_design_semantic,
recognize_ui,
@@ -0,0 +1,85 @@
import { invoke } from '@tauri-apps/api/core';
import { useEffect, useRef, useState } from 'react';
import type { FontAsset } from '../../features/ui-editor/types/FontAsset';
import {
type LoadedUiEditorFontFace,
loadUiEditorFontFace,
} from '../../features/ui-editor/useUiEditorFontFaces';
import type { AssetImporterPreviewProps } from './utils';
export function FontImporterPreview({
projectPath,
selected,
}: AssetImporterPreviewProps) {
const [fontFamily, setFontFamily] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadedRef = useRef<{ face: FontFace; url: string } | null>(null);
useEffect(() => {
let cancelled = false;
let loaded: LoadedUiEditorFontFace | null = null;
if (loadedRef.current) {
document.fonts?.delete(loadedRef.current.face);
URL.revokeObjectURL(loadedRef.current.url);
loadedRef.current = null;
}
setFontFamily(null);
setError(null);
if (!selected?.asset || selected.isDirectory) return;
void (async () => {
try {
const [font] = await invoke<FontAsset[]>(
'prepare_ui_editor_project_fonts',
{ projectPath, assets: [selected.asset] },
);
if (!font) throw new Error('未取得字体元数据');
loaded = await loadUiEditorFontFace(projectPath, font);
if (cancelled) {
URL.revokeObjectURL(loaded.url);
return;
}
document.fonts.add(loaded.face);
loadedRef.current = loaded;
setFontFamily(loaded.cssFamily);
} catch (cause) {
console.error('[ui-editor-font-preview] failed', {
assetId: selected.asset?.id,
relativePath: selected.asset?.localPath,
cause,
});
if (!cancelled) setError('字体样张加载失败');
}
})();
return () => {
cancelled = true;
if (loadedRef.current === loaded && loaded) {
document.fonts?.delete(loaded.face);
URL.revokeObjectURL(loaded.url);
loadedRef.current = null;
}
};
}, [projectPath, selected]);
if (!fontFamily) {
return (
<span className="px-4 text-center text-xs text-(--platform-text-soft)">
{error ?? selected?.name ?? '选择一个字体文件'}
</span>
);
}
return (
<div className="w-full px-5 py-6" style={{ color: '#000', fontFamily }}>
<p className="m-0 text-xs leading-5">
· English Font Preview · 0123456789
</p>
<p className="m-0 mt-3 text-sm leading-6">
Aa Bb Cc!? @#&amp;*()
</p>
<p className="m-0 mt-3 text-lg leading-7"> Genarrative Studio</p>
<p className="m-0 mt-3 text-2xl leading-8">Hello World! </p>
<p className="m-0 mt-3 text-3xl leading-10"> Aa 123</p>
<p className="m-0 mt-3 text-4xl leading-none"> English</p>
</div>
);
}
@@ -0,0 +1,72 @@
import { invoke } from '@tauri-apps/api/core';
import { useEffect, useRef, useState } from 'react';
import { SpriteImagePreview } from '../../features/ui-editor/components/SpriteImagePreview';
import { resolveClientAssetReadUrl } from '../../services/clientApi';
import {
cancelLocalProjectResourcePreviewScope,
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
import type { AssetImporterPreviewProps } from './utils';
type ImagePreviewResponse = { dataUrl: string };
export function ImageImporterPreview({
projectPath,
selected,
}: AssetImporterPreviewProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const scopeIdRef = useRef(createProjectResourcePreviewScopeId());
useEffect(() => {
const previousScopeId = scopeIdRef.current;
scopeIdRef.current = createProjectResourcePreviewScopeId();
cancelLocalProjectResourcePreviewScope(previousScopeId);
return () => cancelLocalProjectResourcePreviewScope(scopeIdRef.current);
}, [projectPath]);
useEffect(() => {
if (!selected || selected.isDirectory) {
setPreviewUrl(null);
return;
}
if (selected.previewUrl) {
setPreviewUrl(selected.previewUrl);
return;
}
if (selected.source === 'remote' && selected.remoteObjectKey) {
void resolveClientAssetReadUrl(selected.remoteObjectKey)
.then(setPreviewUrl)
.catch(() => setPreviewUrl(null));
return;
}
if (
selected.source === 'remote' &&
/^https?:\/\//iu.test(selected.asset?.localPath ?? '')
) {
setPreviewUrl(selected.asset?.localPath ?? null);
return;
}
void invoke<ImagePreviewResponse>('read_local_project_image_preview', {
projectPath,
relativePath: selected.asset?.localPath,
scopeId: scopeIdRef.current,
requestId: createProjectResourcePreviewRequestId(),
})
.then((value) => setPreviewUrl(value.dataUrl))
.catch(() => setPreviewUrl(null));
}, [projectPath, selected]);
return previewUrl ? (
<SpriteImagePreview
src={previewUrl}
alt="选中图片预览"
className="max-h-72 max-w-full object-contain"
/>
) : (
<span className="text-xs text-(--platform-text-soft)">
{selected?.name ?? '选择一个文件'}
</span>
);
}
@@ -9,13 +9,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import {
loadEditorAssetLibrary,
readClientAssetBytes,
resolveClientAssetReadUrl,
} from '../../services/clientApi';
import {
cancelLocalProjectResourcePreviewScope,
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
import { ThemedModal } from '../modal/ThemedModal';
import {
type AssetImporterSettings,
@@ -38,7 +32,10 @@ export type {
RemoteAssetCandidate,
} from './utils';
const ROOT_PATH = '';
// FileManager treats an initial `/` as its Home location, then resolves its
// visible root entries from the empty internal path. Keep this value aligned
// with the image importer behaviour that predates the shared component.
const ROOT_PATH = '/';
export type AssetImporterProps = {
open: boolean;
@@ -57,8 +54,6 @@ type LocalManifestResponse = {
type LocalProjectFilesResponse = { files?: LocalProjectFile[] };
type ImagePreviewResponse = { dataUrl: string };
type ImportAssetResponse = {
id: string;
localPath: string;
@@ -75,29 +70,15 @@ export function AssetImporter({
settings,
}: AssetImporterProps) {
const maxItems = settings.local.requirements.maxItems;
const Preview = settings.preview;
const maxFileSize = settings.local.requirements.maxFileSizeBytes ?? 0;
const [files, setFiles] = useState<ManagerFile[]>([]);
const [selected, setSelected] = useState<ManagerFile[]>([]);
const [preview, setPreview] = useState<string | null>(null);
const [fontPreviewFamily, setFontPreviewFamily] = useState<string | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPath, setCurrentPath] = useState(ROOT_PATH);
const currentPathRef = useRef(ROOT_PATH);
const remoteLoadedRef = useRef(false);
const previewScopeIdRef = useRef(createProjectResourcePreviewScopeId());
const previewFontFaceRef = useRef<FontFace | null>(null);
useEffect(() => {
const previousScopeId = previewScopeIdRef.current;
cancelLocalProjectResourcePreviewScope(previousScopeId);
previewScopeIdRef.current = createProjectResourcePreviewScopeId();
return () => {
cancelLocalProjectResourcePreviewScope(previewScopeIdRef.current);
};
}, [projectPath]);
const loadLocal = useCallback(async () => {
setError(null);
@@ -224,93 +205,9 @@ export function AssetImporter({
setCurrentPath(ROOT_PATH);
setFiles(buildRootFiles(settings));
setSelected([]);
setPreview(null);
if (previewFontFaceRef.current) {
document.fonts?.delete(previewFontFaceRef.current);
previewFontFaceRef.current = null;
}
setFontPreviewFamily(null);
void refresh(ROOT_PATH);
}, [open, refresh, settings]);
useEffect(() => {
const item = selected.at(-1);
if (previewFontFaceRef.current) {
document.fonts?.delete(previewFontFaceRef.current);
previewFontFaceRef.current = null;
}
setFontPreviewFamily(null);
if (!item || item.isDirectory) {
setPreview(null);
return;
}
if (settings.local.localPolicy.destination === 'assets/fonts') {
setPreview(null);
void (async () => {
try {
if (
typeof FontFace === 'undefined' ||
!document.fonts ||
!item.asset
) {
return;
}
const bytes = await invoke<ArrayBuffer>(
'read_ui_editor_font_preview',
{
projectPath,
assetId: item.asset.id,
relativePath: item.asset.localPath,
},
);
const family = `asset-importer-preview-${item.asset.id}`;
const face = new FontFace(family, bytes);
await face.load();
previewFontFaceRef.current = face;
document.fonts.add(face);
setFontPreviewFamily(family);
} catch {
setFontPreviewFamily(null);
}
})();
return;
}
if (item.previewUrl) {
setPreview(item.previewUrl);
return;
}
if (item.source === 'remote' && item.remoteObjectKey) {
void resolveClientAssetReadUrl(item.remoteObjectKey)
.then(setPreview)
.catch(() => setPreview(null));
return;
}
if (
item.source === 'remote' &&
/^https?:\/\//iu.test(item.asset?.localPath ?? '')
) {
setPreview(item.asset?.localPath ?? null);
return;
}
void invoke<ImagePreviewResponse>('read_local_project_image_preview', {
projectPath,
relativePath: item.asset?.localPath,
scopeId: previewScopeIdRef.current,
requestId: createProjectResourcePreviewRequestId(),
})
.then((value) => setPreview(value.dataUrl))
.catch(() => setPreview(null));
}, [projectPath, selected, settings.local.localPolicy.destination]);
useEffect(
() => () => {
if (previewFontFaceRef.current) {
document.fonts?.delete(previewFontFaceRef.current);
}
},
[],
);
const pickLocal = async () => {
setError(null);
setLoading(true);
@@ -415,7 +312,7 @@ export function AssetImporter({
void pickLocal();
};
const handleFolderChange = (path: string) => {
const nextPath = path === '/' ? ROOT_PATH : path;
const nextPath = path || ROOT_PATH;
currentPathRef.current = nextPath;
setCurrentPath(nextPath);
if (
@@ -466,7 +363,7 @@ export function AssetImporter({
) : null}
<div className="min-h-0 flex-1 p-3">
<FileManager
key={`${settings.ariaLabel}:${currentPath}`}
key={`${settings.ariaLabel}:${open ? 'open' : 'closed'}`}
style={{ minHeight: 0, height: '100%', width: '100%' }}
files={files}
layout="grid"
@@ -499,24 +396,7 @@ export function AssetImporter({
<aside className="flex w-72 shrink-0 flex-col border-l border-(--platform-subpanel-border) p-4">
<h3 className="text-xs font-semibold"></h3>
<div className="mt-3 grid min-h-48 place-items-center overflow-hidden rounded-xl border border-(--platform-subpanel-border) bg-black/3">
{preview ? (
<img
src={preview}
alt="选中图片预览"
className="max-h-72 max-w-full object-contain"
/>
) : (
<span
className="text-xs text-(--platform-text-soft)"
style={
fontPreviewFamily
? { fontFamily: fontPreviewFamily, fontSize: '1rem' }
: undefined
}
>
{selected.at(-1)?.name ?? '选择一个文件'}
</span>
)}
<Preview projectPath={projectPath} selected={selected.at(-1)} />
</div>
<div className="mt-auto pt-4">
<p className="m-0 text-xs leading-5 text-(--platform-text-soft)">
@@ -1,6 +1,8 @@
import { Image as ImageIcon, Type } from 'lucide-react';
import { createElement } from 'react';
import { FontImporterPreview } from './FontImporterPreview';
import { ImageImporterPreview } from './ImageImporterPreview';
import type {
AssetImporterSettings,
ImportRequirements,
@@ -61,8 +63,9 @@ function imageImporterSettings(
title: '导入图片素材',
ariaLabel: '导入图片素材',
icon: createElement(ImageIcon, { size: 16 }),
preview: ImageImporterPreview,
local: {
label: '本地项目素材',
label: '本地项目',
typeFilter: imageLocalTypeFilter,
fileDialog: {
title: '选择图片素材',
@@ -98,8 +101,9 @@ export const FONT_IMPORTER_SETTINGS: AssetImporterSettings = {
title: '导入字体',
ariaLabel: '导入字体',
icon: createElement(Type, { size: 16 }),
preview: FontImporterPreview,
local: {
label: '本地项目字体',
label: '本地项目',
typeFilter: fontLocalTypeFilter,
fileDialog: {
title: '选择字体文件',
@@ -1,5 +1,5 @@
import type { FileManagerFile } from '@cubone/react-file-manager';
import type { ReactNode } from 'react';
import type { ComponentType, ReactNode } from 'react';
export type ImportedAsset = {
id: string;
@@ -66,6 +66,7 @@ export type AssetImporterSettings = {
icon?: ReactNode;
local: LocalImporterSettings;
remote?: RemoteImporterSettings;
preview: ComponentType<AssetImporterPreviewProps>;
};
export type ManagerFile = FileManagerFile & {
@@ -75,6 +76,11 @@ export type ManagerFile = FileManagerFile & {
asset?: ImportedAsset;
};
export type AssetImporterPreviewProps = {
projectPath: string;
selected: ManagerFile | undefined;
};
export type ManifestAsset = {
id: string;
localPath: string;
@@ -95,7 +101,7 @@ type RemoteAsset = {
};
type RemoteLibrary = { folders?: RemoteFolder[]; assets?: RemoteAsset[] };
export const PROJECT_ASSETS_PATH = '/本地项目素材';
export const PROJECT_ASSETS_PATH = '/本地项目';
export const REMOTE_ASSETS_PATH = '/云端素材库';
function imagePath(path: string) {
return path.replace(/^\/+/, '').replaceAll('\\', '/');
@@ -0,0 +1,12 @@
export function SpriteImagePreview({
src,
alt,
className,
}: {
src: string | undefined | null;
alt: string;
className?: string;
}) {
if (!src) return null;
return <img src={src} alt={alt} className={className} />;
}
@@ -8,6 +8,12 @@ export type UiEditorFontFaceState = {
status: 'error' | 'loaded' | 'loading';
};
export type LoadedUiEditorFontFace = {
cssFamily: string;
face: FontFace;
url: string;
};
export function uiEditorPrivateFontFamily(assetId: string) {
return `ui-editor-font-${encodeURIComponent(assetId).replaceAll('%', '_')}`;
}
@@ -25,6 +31,58 @@ function fontMimeType(font: FontAsset) {
}
}
export async function loadUiEditorFontFace(
projectPath: string,
font: FontAsset,
): Promise<LoadedUiEditorFontFace> {
if (typeof FontFace === 'undefined' || !document.fonts) {
throw new Error('FontFace unavailable');
}
const cssFamily = uiEditorPrivateFontFamily(font.asset_id);
// eslint-disable-next-line no-console -- temporary font loading diagnostics.
console.info('[ui-editor-font-face] reading font bytes', {
assetId: font.asset_id,
relativePath: font.path,
});
const bytes = await invoke<ArrayBuffer>('read_ui_editor_font_bytes', {
projectPath,
assetId: font.asset_id,
relativePath: font.path,
expectedSha256: font.content_sha256,
});
// eslint-disable-next-line no-console -- temporary font loading diagnostics.
console.info('[ui-editor-font-face] received font bytes', {
assetId: font.asset_id,
bytes: bytes.byteLength,
});
const url = URL.createObjectURL(
new Blob([new Uint8Array(bytes)], { type: fontMimeType(font) }),
);
const face = new FontFace(cssFamily, `url(${JSON.stringify(url)})`, {
style: font.metadata.italic ? 'italic' : 'normal',
weight: String(font.metadata.weight),
});
try {
// eslint-disable-next-line no-console -- temporary font loading diagnostics.
console.info('[ui-editor-font-face] loading FontFace', {
assetId: font.asset_id,
cssFamily,
});
await face.load();
return { cssFamily, face, url };
} catch (cause) {
document.fonts.delete(face);
URL.revokeObjectURL(url);
// eslint-disable-next-line no-console -- temporary font loading diagnostics.
console.error('[ui-editor-font-face] FontFace load failed', {
assetId: font.asset_id,
relativePath: font.path,
cause,
});
throw cause;
}
}
export function useUiEditorFontFaces(
projectPath: string,
fonts: Record<string, FontAsset>,
@@ -69,43 +127,23 @@ export function useUiEditorFontFaces(
for (const font of fontAssets) {
void (async () => {
const cssFamily = uiEditorPrivateFontFamily(font.asset_id);
let url: string | undefined;
let face: FontFace | undefined;
let loaded: LoadedUiEditorFontFace | undefined;
try {
if (typeof FontFace === 'undefined' || !document.fonts) {
throw new Error('FontFace unavailable');
}
const bytes = await invoke<ArrayBuffer>('read_ui_editor_font_bytes', {
projectPath,
assetId: font.asset_id,
relativePath: font.path,
expectedSha256: font.content_sha256,
});
if (cancelled) return;
url = URL.createObjectURL(
new Blob([new Uint8Array(bytes)], { type: fontMimeType(font) }),
);
face = new FontFace(cssFamily, `url(${JSON.stringify(url)})`, {
style: font.metadata.italic ? 'italic' : 'normal',
weight: String(font.metadata.weight),
});
await face.load();
loaded = await loadUiEditorFontFace(projectPath, font);
if (cancelled) {
URL.revokeObjectURL(url);
URL.revokeObjectURL(loaded.url);
return;
}
document.fonts.add(face);
registered.push({ face, url });
document.fonts.add(loaded.face);
registered.push({ face: loaded.face, url: loaded.url });
setStates((current) => ({
...current,
[font.asset_id]: { cssFamily, status: 'loaded' },
}));
} catch {
if (face) {
document.fonts?.delete(face);
}
if (url) {
URL.revokeObjectURL(url);
if (loaded) {
document.fonts?.delete(loaded.face);
URL.revokeObjectURL(loaded.url);
}
if (!cancelled) {
setStates((current) => ({
@@ -45,14 +45,29 @@ export function TextPanel({
onChange({ ...component, content: event.target.value })
}
/>
<ComponentField label="字体资源">
{/* TODO: expose editable imported font assets after FontAsset gets a safe browser resource. */}
<div className="mt-1 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 py-2 text-xs font-normal">
{typeof component.font !== 'string'
? (fonts[component.font.Bound]?.asset_id ?? component.font.Bound)
: '系统字体'}
</div>
</ComponentField>
<ComponentSelect
label="字体资源"
value={fontSourceValue(component.font)}
disabled={readOnly}
onChange={(event) =>
onChange({
...component,
font:
event.target.value === 'SystemFont'
? 'SystemFont'
: { Bound: event.target.value },
})
}
>
<option value="SystemFont"></option>
{Object.values(fonts)
.sort((left, right) => left.asset_id.localeCompare(right.asset_id))
.map((font) => (
<option key={font.asset_id} value={font.asset_id}>
{font.metadata.family_name} {font.metadata.face_name}
</option>
))}
</ComponentSelect>
<ComponentSelect
label="字体样式"
value={component.font_style}
@@ -254,6 +269,10 @@ function fontSizingVariant(sizing: FontSizing): string {
return 'Fixed' in sizing ? 'Fixed' : 'BestFit';
}
function fontSourceValue(font: TextEditorProps['component']['font']): string {
return typeof font === 'string' ? 'SystemFont' : font.Bound;
}
function defaultFontSizing(kind: string): FontSizing {
return kind === 'BestFit' ? { BestFit: { min: 8, max: 32 } } : { Fixed: 14 };
}
@@ -6,6 +6,7 @@ import type {
TextareaHTMLAttributes,
} from 'react';
import { SpriteImagePreview } from '../../../../features/ui-editor/components/SpriteImagePreview';
import type { UIDesignImageId } from '../../../../features/ui-editor/types/UIDesignImageId';
import type { UIDesignImageRole } from '../../../../features/ui-editor/types/UIDesignImageRole';
import { uiEditorPrivateFontFamily } from '../../../../features/ui-editor/useUiEditorFontFaces';
@@ -524,13 +525,11 @@ function SpriteInspector({
return (
<div className="mt-4 space-y-4">
<div className="grid aspect-square place-items-center overflow-hidden rounded-xl border border-(--platform-subpanel-border) bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]">
{previewUrl ? (
<img
src={previewUrl}
alt={sprite.metadata.name}
className="size-full object-contain"
/>
) : null}
<SpriteImagePreview
src={previewUrl}
alt={sprite.metadata.name}
className="size-full object-contain"
/>
</div>
<InspectorInput
label="完整名称"
@@ -16,9 +16,9 @@ UI Editor Inspector 的全局只读状态唯一来源是 `controller.editor.isLo
UI Editor 的 `State.font_assets` 正式承载项目字体面资源;`FontAsset` 包含项目资产 ID、受控项目相对路径、内容 SHA-256,以及由 Rust 解析的 family、face、weight、italic、格式和源文件名。`TextComponent.font` 直接绑定一个具体字体面;原 `font_style` 是与字体面元数据重复且可能矛盾的状态,直接从 Rust 权威类型和生成 TypeScript 类型中删除,不保留会话态兼容层。UI Editor 整体 State 仍只属于当前桌面会话,不新增持久化合同。
字体资源发现与 Sprite 保持同一项目资产语义:字体 importer 打开后读取 manifest 与项目文件列表,只展示已登记且具有字体 MIME 或受支持字体扩展名的项目资产;不扫描或接纳未登记文件,不展示云端素材库。从电脑导入使用 Tauri 系统文件选择器,Rust 整批读取普通文件,拒绝符号链接、集合字体、超过 `8 MiB` 的单文件、超过 `64` 个字体面或 `32 MiB` 项目总量,完成真实签名、字体表、名称与 weight/style 解析后复制到 `assets/fonts/` 并登记 manifest。内容相同的字体复用已登记资源;Sprite 与 Font 的 State 批量加入都采用幂等合并:相同 ID 且完整资源相等时跳过,同 ID 数据冲突时整批失败。删除只移除会话 State 资源并清空相应 `Image.target_graphic``Text.font` 引用,不删除项目文件或 manifest 条目。
字体资源发现与 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 不共享浏览器注册名。WebView 加载失败或字体缺少当前文本字形时不阻塞后续阶段,Inspector 显示非阻断提示并回退系统字体;悬空字体 ID 继续由 prerequisite 阻止。`BestFit` 保持现有取最大字号的近似,不在本次字体闭环中扩展为测量算法。
## 2026-08-18 UI Editor 图片 / 字体通用 AssetImporter 契约