diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 2d5b61287..3500627af 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1682,6 +1682,7 @@ dependencies = [ "tempfile", "tokio", "ts-rs", + "ttf-parser", "typed_floats", "unicode-normalization", "url", @@ -5973,6 +5974,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.28.0" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 45fe12272..1fb68b5d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -49,6 +49,7 @@ tauri-plugin-dialog = "2.7.1" tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } tauri-plugin-opener = "2" tempfile = "3" +ttf-parser = "0.25.1" tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] } url = "2" unicode-normalization = "0.1" diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index cbd4a3636..9ecc16c8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,4 +1,9 @@ use super::*; +use crate::ui_editor::resource::font::FontAsset; + +const UI_EDITOR_FONT_MAX_FILE_SIZE: u64 = 8 * 1024 * 1024; +const UI_EDITOR_FONT_MAX_TOTAL_SIZE: u64 = 32 * 1024 * 1024; +const UI_EDITOR_FONT_MAX_COUNT: usize = 64; #[tauri::command] pub(crate) fn init_local_game_project( @@ -1531,6 +1536,422 @@ pub(crate) fn import_ui_editor_local_files( Ok(LocalImportResult { assets }) } +fn is_ui_editor_font_manifest_asset(asset: &GameCreationAppAssetManifestEntry) -> bool { + let media_type = asset.media_type.trim().to_ascii_lowercase(); + let extension = Path::new(&asset.local_path) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + matches!( + media_type.as_str(), + "font/ttf" | "font/otf" | "font/woff" | "font/woff2" + ) || matches!(extension.as_str(), "ttf" | "otf" | "woff" | "woff2") +} + +fn read_registered_ui_editor_font( + root: &Path, + asset: &GameCreationAppAssetManifestEntry, +) -> Result<(FontAsset, Vec), String> { + if !is_ui_editor_font_manifest_asset(asset) { + return Err("项目资产不是受支持的字体候选".to_string()); + } + let target = resolve_local_project_path(root, &asset.local_path)?; + let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("项目字体必须是普通文件".to_string()); + } + if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { + return Err("项目字体超过 8 MiB 限制".to_string()); + } + let bytes = fs::read(&target).map_err(|_| "读取项目字体失败".to_string())?; + let source_file_name = target + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("font") + .to_string(); + let mut font = FontAsset::from_verified_bytes( + asset.id.clone(), + asset.local_path.clone(), + source_file_name, + &bytes, + )?; + if let Some(copied_name) = target.file_name().and_then(|value| value.to_str()) { + let hash_prefix = format!("{}-", font.content_sha256); + if let Some(original_name) = copied_name.strip_prefix(&hash_prefix) { + if !original_name.is_empty() { + font.metadata.source_file_name = original_name.to_string(); + } + } + } + Ok((font, bytes)) +} + +fn find_registered_ui_editor_font<'a>( + manifest: &'a GameCreationAppManifest, + asset_id: &str, + relative_path: &str, +) -> Result<&'a GameCreationAppAssetManifestEntry, String> { + manifest + .assets + .iter() + .find(|asset| asset.id == asset_id && asset.local_path == relative_path) + .ok_or_else(|| "字体不是当前项目已登记资产".to_string()) +} + +#[tauri::command] +pub(crate) fn prepare_ui_editor_project_fonts( + project_path: String, + assets: Vec, +) -> Result, String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + if assets.len() > UI_EDITOR_FONT_MAX_COUNT { + return Err("一次最多选择 64 个字体面".to_string()); + } + let manifest = read_existing_manifest_for_project(root)?; + let mut fonts = Vec::with_capacity(assets.len()); + for selected in assets { + let asset = find_registered_ui_editor_font(&manifest, &selected.id, &selected.local_path)?; + fonts.push(read_registered_ui_editor_font(root, asset)?.0); + } + Ok(fonts) +} + +#[tauri::command] +pub(crate) fn import_ui_editor_local_fonts( + project_path: String, + source_paths: Vec, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.upload")?; + let _lock = acquire_project_write_lock(root, "asset.upload")?; + if source_paths.len() > UI_EDITOR_FONT_MAX_COUNT { + return Err("一次最多选择 64 个字体面".to_string()); + } + + let mut inputs = Vec::new(); + let mut input_hashes = std::collections::BTreeSet::new(); + for source in source_paths { + let path = Path::new(source.trim()); + let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("只能导入普通字体文件".to_string()); + } + if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { + return Err("字体超过 8 MiB 限制".to_string()); + } + let bytes = fs::read(path).map_err(|_| "读取本地字体失败".to_string())?; + let source_file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("font") + .to_string(); + let validated = FontAsset::from_verified_bytes( + "font-validation", + "assets/fonts/validation.ttf", + source_file_name.clone(), + &bytes, + )?; + if input_hashes.insert(validated.content_sha256.clone()) { + inputs.push((source_file_name, bytes, validated)); + } + } + + let manifest = read_existing_manifest_for_project(root)?; + let mut existing_by_hash = std::collections::BTreeMap::new(); + let mut existing_total_size = 0u64; + let mut existing_count = 0usize; + for asset in manifest + .assets + .iter() + .filter(|asset| is_ui_editor_font_manifest_asset(asset)) + { + let Ok((font, bytes)) = read_registered_ui_editor_font(root, asset) else { + continue; + }; + existing_total_size = existing_total_size.saturating_add(bytes.len() as u64); + existing_count += 1; + existing_by_hash + .entry(font.content_sha256.clone()) + .or_insert(font); + } + let new_inputs = inputs + .iter() + .filter(|(_, _, font)| !existing_by_hash.contains_key(&font.content_sha256)) + .collect::>(); + let new_total_size = new_inputs.iter().fold(0u64, |total, (_, bytes, _)| { + total.saturating_add(bytes.len() as u64) + }); + if existing_count.saturating_add(new_inputs.len()) > UI_EDITOR_FONT_MAX_COUNT { + return Err("项目字体面最多 64 个".to_string()); + } + if existing_total_size.saturating_add(new_total_size) > UI_EDITOR_FONT_MAX_TOTAL_SIZE { + return Err("项目字体总量超过 32 MiB 限制".to_string()); + } + + if !new_inputs.is_empty() { + advance_agent_runtime_project_revision_locked(root)?; + let font_root = root.join("assets/fonts"); + fs::create_dir_all(&font_root).map_err(|_| "创建项目字体目录失败".to_string())?; + } + let mut result = Vec::with_capacity(inputs.len()); + for (source_file_name, bytes, validated) in inputs { + if let Some(existing) = existing_by_hash.get(&validated.content_sha256) { + result.push(existing.clone()); + continue; + } + let safe_stem = sanitize_file_name( + Path::new(&source_file_name) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("font"), + ); + let relative_path = format!( + "assets/fonts/{}-{safe_stem}.{}", + validated.content_sha256, + validated.metadata.format.extension() + ); + let target = resolve_local_project_path(root, &relative_path)?; + fs::write(&target, &bytes).map_err(|_| "写入项目字体失败".to_string())?; + let registered = register_local_asset_entry( + root, + &relative_path, + "font", + validated.metadata.format.media_type(), + "font", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + )?; + let font = FontAsset::from_verified_bytes( + registered.id, + registered.local_path, + source_file_name, + &bytes, + )?; + existing_by_hash.insert(font.content_sha256.clone(), font.clone()); + result.push(font); + } + Ok(LocalImportResult { + assets: result + .into_iter() + .map(|font| ImportedAsset { + id: font.asset_id.as_str().to_string(), + local_path: font.path, + asset_kind: Some("font".to_string()), + }) + .collect(), + }) +} + +fn read_checked_ui_editor_font( + project_path: &str, + asset_id: &str, + relative_path: &str, + expected_sha256: &str, +) -> Result<(FontAsset, Vec), 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, relative_path)?; + let (font, bytes) = read_registered_ui_editor_font(root, asset)?; + if font.content_sha256 != expected_sha256 { + return Err("字体文件内容与资源摘要不一致".to_string()); + } + Ok((font, bytes)) +} + +#[tauri::command] +pub(crate) fn read_ui_editor_font_bytes( + project_path: String, + asset_id: String, + relative_path: String, + expected_sha256: String, +) -> Result { + read_checked_ui_editor_font( + &project_path, + asset_id.trim(), + relative_path.trim(), + expected_sha256.trim(), + ) + .map(|(_, bytes)| tauri::ipc::Response::new(bytes)) +} + +#[tauri::command] +pub(crate) fn check_ui_editor_font_glyph_coverage( + project_path: String, + asset_id: String, + relative_path: String, + expected_sha256: String, + content: String, +) -> Result { + let (font, bytes) = read_checked_ui_editor_font( + &project_path, + asset_id.trim(), + relative_path.trim(), + expected_sha256.trim(), + )?; + font.has_missing_glyphs(&bytes, &content) +} + +#[cfg(test)] +mod ui_editor_font_tests { + use super::*; + + fn fixture_font_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../public/fusion-pixel.ttf") + } + + #[test] + fn registered_font_reader_requires_manifest_identity_and_verified_bytes() { + let project = tempfile::tempdir().expect("project tempdir"); + let root = project.path(); + init_local_game_project_at(root, "font-project", "Font Project") + .expect("initialize project"); + let relative_path = "assets/fonts/fusion-pixel.ttf"; + let target = root.join(relative_path); + fs::create_dir_all(target.parent().expect("font parent")).expect("create font parent"); + fs::copy(fixture_font_path(), &target).expect("copy font fixture"); + let registered = register_local_asset_entry( + root, + relative_path, + "font", + "font/ttf", + "font", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register font"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let entry = + find_registered_ui_editor_font(&manifest, ®istered.id, ®istered.local_path) + .expect("find registered font"); + let (font, bytes) = read_registered_ui_editor_font(root, entry).expect("read font"); + + assert_eq!(font.asset_id.as_str(), registered.id); + assert_eq!(font.path, relative_path); + assert_eq!(font.content_sha256.len(), 64); + assert!(!bytes.is_empty()); + assert!(find_registered_ui_editor_font(&manifest, "missing", relative_path).is_err()); + } + + #[test] + fn local_font_import_copies_registers_and_reuses_identical_content() { + let project = tempfile::tempdir().expect("project tempdir"); + let root = project.path(); + init_local_game_project_at(root, "font-project", "Font Project") + .expect("initialize project"); + let source_path = fixture_font_path().to_string_lossy().into_owned(); + let revision_before = read_game_creator_agent_runtime_project_revision(root) + .expect("read initial revision") + .revision; + + let first = import_ui_editor_local_fonts( + root.to_string_lossy().into_owned(), + vec![source_path.clone()], + ) + .expect("import font"); + assert_eq!(first.assets.len(), 1); + let imported = &first.assets[0]; + assert!(imported.local_path.starts_with("assets/fonts/")); + assert!(imported.local_path.ends_with("-fusion-pixel.ttf")); + assert!(root.join(&imported.local_path).is_file()); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + assert_eq!( + manifest + .assets + .iter() + .filter(|asset| is_ui_editor_font_manifest_asset(asset)) + .count(), + 1 + ); + assert!(manifest + .assets + .iter() + .any(|asset| { asset.id == imported.id && asset.local_path == imported.local_path })); + let revision_after_first = read_game_creator_agent_runtime_project_revision(root) + .expect("read imported revision") + .revision; + assert_eq!(revision_after_first, revision_before + 1); + + let second = + import_ui_editor_local_fonts(root.to_string_lossy().into_owned(), vec![source_path]) + .expect("reimport identical font"); + assert_eq!(second, first); + let manifest = read_existing_manifest_for_project(root).expect("read reused manifest"); + assert_eq!( + manifest + .assets + .iter() + .filter(|asset| is_ui_editor_font_manifest_asset(asset)) + .count(), + 1 + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .expect("read reused revision") + .revision, + revision_after_first + ); + } + + #[cfg(unix)] + #[test] + fn registered_font_reader_rejects_symlink() { + use std::os::unix::fs::symlink; + + let project = tempfile::tempdir().expect("project tempdir"); + let root = project.path(); + let relative_path = "assets/fonts/linked.ttf"; + let target = root.join(relative_path); + fs::create_dir_all(target.parent().expect("font parent")).expect("create font parent"); + symlink(fixture_font_path(), &target).expect("create font symlink"); + let entry = GameCreationAppAssetManifestEntry { + id: "font-linked".to_string(), + kind: "font".to_string(), + media_type: "font/ttf".to_string(), + local_path: relative_path.to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }; + + assert_eq!( + read_registered_ui_editor_font(root, &entry).expect_err("reject symlink"), + "项目文件路径不能包含符号链接" + ); + } +} + #[tauri::command] pub(crate) async fn import_ui_editor_remote_assets( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index bffc2830c..5680023a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -950,14 +950,6 @@ struct UploadLocalAssetResult { manifest_path: String, } -#[derive(Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct CreateUiDesignResourceResult { - asset: UploadLocalAssetResult, - manifest: GameCreationAppManifest, - committed_project_revision: u64, -} - #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct ImportCanvasExportResult { @@ -984,7 +976,7 @@ struct LocalImportResult { type RemoteImportResult = LocalImportResult; -#[derive(Debug, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct ImportedAsset { id: String, @@ -2287,7 +2279,6 @@ fn main() { read_game_creator_mcp_catalog, upload_local_asset, register_local_asset, - create_ui_design_resource, derive_local_project_resource, list_pending_local_project_resource_edits, resume_local_project_resource_edit, @@ -2299,6 +2290,10 @@ fn main() { import_canvas_export, sync_canvas_project_assets, import_ui_editor_local_files, + import_ui_editor_local_fonts, + prepare_ui_editor_project_fonts, + read_ui_editor_font_bytes, + check_ui_editor_font_glyph_coverage, import_ui_editor_remote_assets, suggest_ui_design_semantic, recognize_ui, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs index 0c5981e76..776f4b2e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs @@ -1,10 +1,213 @@ use crate::ui_editor::utils::FontAssetId; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use ts_rs::TS; -// TODO -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub enum FontFormat { + TrueType, + OpenType, + Woff, + Woff2, +} + +impl FontFormat { + pub fn media_type(self) -> &'static str { + match self { + Self::TrueType => "font/ttf", + Self::OpenType => "font/otf", + Self::Woff => "font/woff", + Self::Woff2 => "font/woff2", + } + } + + pub fn extension(self) -> &'static str { + match self { + Self::TrueType => "ttf", + Self::OpenType => "otf", + Self::Woff => "woff", + Self::Woff2 => "woff2", + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct FontAssetMetadata { + pub(crate) family_name: String, + pub(crate) face_name: String, + pub(crate) weight: u16, + pub(crate) italic: bool, + pub(crate) format: FontFormat, + pub(crate) source_file_name: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct FontAsset { - asset_id: FontAssetId, + pub(crate) asset_id: FontAssetId, + pub(crate) metadata: FontAssetMetadata, + pub(crate) path: String, + pub(crate) content_sha256: String, +} + +impl FontAsset { + pub(crate) fn from_verified_bytes( + asset_id: impl Into, + path: impl Into, + source_file_name: impl Into, + bytes: &[u8], + ) -> Result { + let asset_id = + FontAssetId::new(asset_id.into()).map_err(|_| "字体资源 ID 无效".to_string())?; + let path = path.into(); + if path.trim().is_empty() || path.trim() != path { + return Err("字体资源路径无效".to_string()); + } + let source_file_name = source_file_name.into(); + let format = detect_font_format(bytes)?; + let face = ttf_parser::Face::parse(bytes, 0) + .map_err(|_| format!("当前 Rust 解析器无法安全解析 {} 字体", format.extension()))?; + if ttf_parser::fonts_in_collection(bytes).is_some() { + return Err("暂不支持字体集合文件".to_string()); + } + let family_name = font_name( + &face, + &[ + ttf_parser::name_id::TYPOGRAPHIC_FAMILY, + ttf_parser::name_id::FAMILY, + ], + ) + .ok_or_else(|| "字体缺少可用的 family 名称".to_string())?; + let face_name = font_name( + &face, + &[ + ttf_parser::name_id::TYPOGRAPHIC_SUBFAMILY, + ttf_parser::name_id::SUBFAMILY, + ], + ) + .unwrap_or_else(|| "Regular".to_string()); + let content_sha256 = format!("{:x}", Sha256::digest(bytes)); + Ok(Self { + asset_id, + metadata: FontAssetMetadata { + family_name, + face_name, + weight: face.weight().to_number().clamp(1, 1000), + italic: face.is_italic() || face.is_oblique(), + format, + source_file_name, + }, + path, + content_sha256, + }) + } + + pub(crate) fn has_missing_glyphs(&self, bytes: &[u8], content: &str) -> Result { + let digest = format!("{:x}", Sha256::digest(bytes)); + if digest != self.content_sha256 { + return Err("字体文件内容与资源摘要不一致".to_string()); + } + let face = + ttf_parser::Face::parse(bytes, 0).map_err(|_| "字体文件无法安全解析".to_string())?; + Ok(content.chars().any(|character| { + !character.is_whitespace() + && !character.is_control() + && !matches!(character, '\u{fe0e}' | '\u{fe0f}') + && face.glyph_index(character).is_none() + })) + } +} + +fn detect_font_format(bytes: &[u8]) -> Result { + if bytes.starts_with(b"wOFF") { + return Ok(FontFormat::Woff); + } + if bytes.starts_with(b"wOF2") { + return Ok(FontFormat::Woff2); + } + if bytes.starts_with(b"OTTO") { + return Ok(FontFormat::OpenType); + } + if bytes.starts_with(&[0x00, 0x01, 0x00, 0x00]) || bytes.starts_with(b"true") { + return Ok(FontFormat::TrueType); + } + if bytes.starts_with(b"ttcf") { + return Err("暂不支持字体集合文件".to_string()); + } + Err("文件不是受支持的 TTF/OTF/WOFF/WOFF2 字体".to_string()) +} + +fn font_name(face: &ttf_parser::Face<'_>, ids: &[u16]) -> Option { + ids.iter().find_map(|id| { + face.names() + .into_iter() + .filter(|name| name.name_id == *id) + .find_map(|name| name.to_string()) + .map(|name| name.trim().to_string()) + .filter(|name| { + !name.is_empty() + && name.chars().count() <= 200 + && !name.chars().any(char::is_control) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_font() -> Vec { + std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../public/fusion-pixel.ttf"), + ) + .expect("read checked-in font fixture") + } + + #[test] + fn verified_font_asset_extracts_face_metadata_and_digest() { + let bytes = fixture_font(); + let font = FontAsset::from_verified_bytes( + "font-asset", + "assets/fonts/fusion-pixel.ttf", + "fusion-pixel.ttf", + &bytes, + ) + .expect("parse font fixture"); + + assert!(!font.metadata.family_name.is_empty()); + assert!(!font.metadata.face_name.is_empty()); + assert_eq!(font.metadata.format, FontFormat::TrueType); + assert_eq!(font.content_sha256.len(), 64); + assert!(!font + .has_missing_glyphs(&bytes, "ABC 123") + .expect("check common glyphs")); + assert!(font + .has_missing_glyphs(&bytes, "\u{10ffff}") + .expect("check missing glyph")); + } + + #[test] + fn verified_font_asset_rejects_collections_and_unparseable_web_fonts() { + assert_eq!( + FontAsset::from_verified_bytes( + "font-asset", + "assets/fonts/font.ttc", + "font.ttc", + b"ttcf\0\x01\0\0", + ) + .expect_err("reject collection"), + "暂不支持字体集合文件" + ); + assert!(FontAsset::from_verified_bytes( + "font-asset", + "assets/fonts/font.woff2", + "font.woff2", + b"wOF2invalid", + ) + .expect_err("reject unparseable web font") + .contains("无法安全解析")); + } } diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx index 61632795d..7cd725d79 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx @@ -18,6 +18,7 @@ import { } from '../../services/projectResourcePreviewTransport'; import { ThemedModal } from '../modal/ThemedModal'; import { + type AssetImporterMode, buildProjectFiles, buildRemoteFolderFiles, buildRootFiles, @@ -40,6 +41,7 @@ export type UiEditorImageImporterProps = { maxItems: number; maxFileSize: number; acceptedMediaTypes: readonly string[]; + mode?: AssetImporterMode; }; type LocalProjectFile = { path: string }; @@ -67,6 +69,7 @@ export function AssetImporter({ maxItems, maxFileSize, acceptedMediaTypes, + mode = 'image', }: UiEditorImageImporterProps) { const [files, setFiles] = useState([]); const [selected, setSelected] = useState([]); @@ -115,25 +118,34 @@ export function AssetImporter({ filesFromDisk .map((file) => byPath.get(file.path)) .filter(Boolean) as ManifestAsset[], + mode, ); - }, [projectPath]); + }, [mode, projectPath]); - const replaceProjectFiles = useCallback((projectFiles: ManagerFile[]) => { - setFiles((current) => [ - ...buildRootFiles(), - ...projectFiles, - ...current.filter((file) => file.source === 'remote'), - ]); - }, []); + const replaceProjectFiles = useCallback( + (projectFiles: ManagerFile[]) => { + setFiles((current) => [ + ...buildRootFiles(mode), + ...projectFiles, + ...(mode === 'image' + ? current.filter((file) => file.source === 'remote') + : []), + ]); + }, + [mode], + ); - const replaceRemoteFiles = useCallback((remoteFiles: ManagerFile[]) => { - setFiles((current) => [ - ...buildRootFiles(), - ...current.filter((file) => file.source === 'local'), - ...remoteFiles, - ]); - remoteLoadedRef.current = true; - }, []); + const replaceRemoteFiles = useCallback( + (remoteFiles: ManagerFile[]) => { + setFiles((current) => [ + ...buildRootFiles(mode), + ...current.filter((file) => file.source === 'local'), + ...remoteFiles, + ]); + remoteLoadedRef.current = true; + }, + [mode], + ); const loadRemote = useCallback(async () => { const library = await loadEditorAssetLibrary(); @@ -150,7 +162,8 @@ export function AssetImporter({ const shouldRefreshProject = path === ROOT_PATH || path.startsWith(PROJECT_ASSETS_PATH); const shouldRefreshRemote = - path === ROOT_PATH || path.startsWith(REMOTE_ASSETS_PATH); + mode === 'image' && + (path === ROOT_PATH || path.startsWith(REMOTE_ASSETS_PATH)); const projectResult = shouldRefreshProject ? await Promise.allSettled([loadLocal()]).then(([result]) => result) : null; @@ -188,7 +201,7 @@ export function AssetImporter({ setLoading(false); } }, - [loadLocal, loadRemote, replaceProjectFiles], + [loadLocal, loadRemote, mode, replaceProjectFiles], ); useEffect(() => { @@ -196,14 +209,18 @@ export function AssetImporter({ currentPathRef.current = ROOT_PATH; remoteLoadedRef.current = false; setCurrentPath(ROOT_PATH); - setFiles(buildRootFiles()); + setFiles(buildRootFiles(mode)); setSelected([]); setPreview(null); void refresh(ROOT_PATH); - }, [open, refresh]); + }, [mode, open, refresh]); useEffect(() => { const item = selected.at(-1); + if (mode === 'font') { + setPreview(null); + return; + } if (!item || item.isDirectory) { setPreview(null); return; @@ -233,28 +250,49 @@ export function AssetImporter({ }) .then((value) => setPreview(value.dataUrl)) .catch(() => setPreview(null)); - }, [projectPath, selected]); + }, [mode, projectPath, selected]); const pickLocal = async () => { setError(null); setLoading(true); try { const paths = await openNativeFileDialog({ - title: '选择图片素材', + title: mode === 'font' ? '选择字体文件' : '选择图片素材', multiple: true, directory: false, - filters: [ - { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp'] }, - ], + filters: + mode === 'font' + ? [ + { + name: 'Fonts', + extensions: ['ttf', 'otf', 'woff', 'woff2'], + }, + ] + : [ + { + name: 'Images', + extensions: ['png', 'jpg', 'jpeg', 'webp'], + }, + ], }); const sourcePaths = Array.isArray(paths) ? paths : paths ? [paths] : []; if (!sourcePaths.length) return; if (sourcePaths.length > maxItems) - throw new Error(`最多选择 ${maxItems} 张图片`); - const result = await invoke<{ assets: ImportAssetResponse[] }>( - 'import_ui_editor_local_files', - { projectPath, sourcePaths, maxFileSize }, - ); + throw new Error( + mode === 'font' + ? `最多选择 ${maxItems} 个字体面` + : `最多选择 ${maxItems} 张图片`, + ); + const result = + mode === 'font' + ? await invoke<{ assets: ImportAssetResponse[] }>( + 'import_ui_editor_local_fonts', + { projectPath, sourcePaths }, + ) + : await invoke<{ assets: ImportAssetResponse[] }>( + 'import_ui_editor_local_files', + { projectPath, sourcePaths, maxFileSize }, + ); onImport( result.assets.map((asset) => ({ id: asset.id, @@ -331,7 +369,11 @@ export function AssetImporter({ const nextPath = path || ROOT_PATH; currentPathRef.current = nextPath; setCurrentPath(nextPath); - if (nextPath.startsWith(REMOTE_ASSETS_PATH) && !remoteLoadedRef.current) { + if ( + mode === 'image' && + nextPath.startsWith(REMOTE_ASSETS_PATH) && + !remoteLoadedRef.current + ) { setLoading(true); setError(null); void loadRemote() @@ -346,12 +388,12 @@ export function AssetImporter({

- 导入图片素材 + {mode === 'font' ? '导入字体' : '导入图片素材'}

- {renderMode === 'final-preview' ? ( - - ) : null} {Object.keys(controller.images).length} 张界面图 ·{' '} {Object.keys(controller.sprites).length} 项素材 @@ -351,12 +340,12 @@ export function PreviewWorkspace({ 清空 + ); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index 6deddddfb..b1bf554e8 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -20,7 +20,6 @@ type NodePointerDown = ( type UiTreeRendererProps = { tree: UITree | null; renderMode: UiEditorRenderMode; - showFrame: boolean; selectedNodeId: NodeId | null; resources: PreviewComponentResources; onSelectNode: (id: NodeId) => void; @@ -57,7 +56,6 @@ function RenderNode({ node, isRoot, renderMode, - showFrame, selectedNodeId, resources, onSelectNode, @@ -78,14 +76,13 @@ function RenderNode({ } const isEditorOverlay = renderMode === 'editor-overlay'; - const isFrameVisible = isEditorOverlay || showFrame; return (
- {isFrameVisible && node.metadata.name ? ( + {isEditorOverlay && node.metadata.name ? ( {node.metadata.name} ) : null} - {renderMode === 'final-preview' - ? node.components.map((component, index) => ( - - )) - : null} + {node.components.map((component, index) => ( + + ))} {node.children.map((child) => ( ))} - {isFrameVisible && !isRoot && selectedNodeId === node.id + {isEditorOverlay && !isRoot && selectedNodeId === node.id ? RESIZE_HANDLES.map((handle) => (
; sprites: Record; fonts: Record; + fontFaces: Record; }; diff --git a/apps/ai-game-creator-shell/tests/assetImporter.test.ts b/apps/ai-game-creator-shell/tests/assetImporter.test.ts new file mode 100644 index 000000000..fbe973a26 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/assetImporter.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildProjectFiles, + buildRootFiles, +} from '../src/components/AssetImporter/utils'; + +describe('AssetImporter font mode', () => { + it('discovers only registered font candidates and hides the remote root', () => { + const assets = [ + { + id: 'font-1', + localPath: 'assets/fonts/body.ttf', + mediaType: 'font/ttf', + }, + { + id: 'image-1', + localPath: 'assets/hero.png', + mediaType: 'image/png', + }, + { + id: 'font-by-extension', + localPath: 'assets/fonts/title.otf', + mediaType: 'application/octet-stream', + }, + ]; + + const files = buildProjectFiles(assets, 'font').filter( + (entry) => !entry.isDirectory, + ); + expect(files.map((entry) => entry.asset?.id)).toEqual([ + 'font-1', + 'font-by-extension', + ]); + expect(buildRootFiles('font').map((entry) => entry.name)).toEqual([ + '本地项目字体', + ]); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts index d9025049e..13f929ef2 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts @@ -182,33 +182,6 @@ describe('项目资源投影', () => { expect(first.map(({ id }) => id)).toEqual(second.map(({ id }) => id)); }); - it('把 manifest 中的 UI 设计登记为美术资源', () => { - const manifest = createGameCreationAppManifest( - 'ui-resource', - 'UI 资源测试', - ); - manifest.assets = [ - { - id: 'ui-1', - kind: 'UI', - mediaType: 'application/json', - localPath: 'ui/UI 设计 1.json', - source: { kind: 'generated', resourceId: 'ui:1' }, - }, - ]; - - expect(projectResourcesFromReadModels(manifest, [], [])).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'asset:ui-1', - category: 'art', - subtype: 'UI', - manifestAssetId: 'ui-1', - }), - ]), - ); - }); - it('只从 manifest 投影版本并保留直接父子关系', () => { const manifest = createGameCreationAppManifest( 'version-projection', diff --git a/apps/ai-game-creator-shell/tests/uiEditorFontFaces.test.ts b/apps/ai-game-creator-shell/tests/uiEditorFontFaces.test.ts new file mode 100644 index 000000000..841084ab6 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorFontFaces.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom + +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { FontAsset } from '../src/features/ui-editor/types/FontAsset'; +import { useUiEditorFontFaces } from '../src/features/ui-editor/useUiEditorFontFaces'; + +const { invoke } = vi.hoisted(() => ({ + invoke: vi.fn(async () => new Uint8Array([0, 1, 2, 3]).buffer), +})); + +vi.mock('@tauri-apps/api/core', () => ({ invoke })); + +const FONT: FontAsset = { + asset_id: 'font-1', + metadata: { + family_name: '测试字体', + face_name: 'Regular', + weight: 400, + italic: false, + format: 'TrueType', + source_file_name: 'test.ttf', + }, + path: 'assets/fonts/test.ttf', + content_sha256: 'a'.repeat(64), +}; + +afterEach(() => { + vi.restoreAllMocks(); + invoke.mockClear(); +}); + +describe('useUiEditorFontFaces', () => { + it('loads registered bytes into a private FontFace and releases browser resources', async () => { + const add = vi.fn(); + const remove = vi.fn(); + Object.defineProperty(document, 'fonts', { + configurable: true, + value: { add, delete: remove }, + }); + const load = vi.fn(async function (this: FontFace) { + return this; + }); + vi.stubGlobal( + 'FontFace', + class { + load = load; + constructor( + readonly family: string, + readonly source: string, + readonly descriptors?: FontFaceDescriptors, + ) {} + }, + ); + const createObjectURL = vi.fn(() => 'blob:font-preview'); + const revokeObjectURL = vi.fn(); + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }); + + const { result, unmount } = renderHook(() => + useUiEditorFontFaces('/project', { [FONT.asset_id]: FONT }), + ); + + await waitFor(() => { + expect(result.current['font-1']?.status).toBe('loaded'); + }); + expect(invoke).toHaveBeenCalledWith('read_ui_editor_font_bytes', { + projectPath: '/project', + assetId: 'font-1', + relativePath: FONT.path, + expectedSha256: FONT.content_sha256, + }); + expect(add).toHaveBeenCalledTimes(1); + expect(createObjectURL).toHaveBeenCalledTimes(1); + + unmount(); + expect(remove).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:font-preview'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 2836d37e0..a9761c430 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -10,8 +10,6 @@ import { import { createElement, type ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import type { State } from '../src/features/ui-editor/types/State'; - vi.mock('../src/components/AssetImporter', () => ({ AssetImporter: ({ open }: { open: boolean }) => open ? createElement('div', { role: 'dialog' }, '素材导入器') : null, @@ -23,56 +21,8 @@ vi.mock('../src/components/modal/ThemedModal', () => ({ })); import UiEditorPage from '../src/view/ui-editor'; -import { InspectorSidebar } from '../src/view/ui-editor/components/Inspector/InspectorSidebar'; import { useUiEditorPage } from '../src/view/ui-editor/useUiEditorPage'; -const SELECTABLE_STATE: State = { - ui_design_images: { - page: { - metadata: { name: '主界面', role: 'Page', slave_to: null }, - path: 'assets/page.png', - pixel_size: [100, 80], - pixels_per_unit: 1, - }, - }, - sprite_assets: { - button: { - asset_id: 'button', - metadata: { name: '按钮素材', asset_type: '' }, - path: 'assets/button.png', - pixel_size: [32, 32], - pixels_per_unit: 1, - border: { left: 0, right: 0, top: 0, bottom: 0 }, - }, - }, - font_assets: {}, - ui_trees: [ - { - src_ui_design: 'page', - root: { - id: 'page-root', - transform: { - anchor_min: [0, 0], - anchor_max: [1, 1], - offset_min: [0, 0], - offset_max: [0, 0], - }, - metadata: { - name: '根节点', - description: '', - layout_status: 'Passed', - components_status: 'Pending', - allow_llm_edit_layout: true, - allow_llm_edit_component: true, - source: 'System', - }, - components: [], - children: [], - }, - }, - ], -}; - describe('UiEditorPage', () => { it('renders split input tools over a real empty State', () => { render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); @@ -82,6 +32,7 @@ describe('UiEditorPage', () => { ).toBeTruthy(); expect(screen.getByRole('button', { name: '导入界面图' })).toBeTruthy(); expect(screen.getByRole('button', { name: '导入独立素材' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '导入字体' })).toBeTruthy(); expect(screen.getByText('从界面图开始')).toBeTruthy(); expect(screen.queryByText('Pause Dialog')).toBeNull(); }); @@ -105,28 +56,45 @@ describe('UiEditorPage', () => { expect(screen.getByText('素材导入器')).toBeTruthy(); }); - it('shows the asset inspector after selecting an asset from a selected node', () => { + it('switches from a selected node to the sprite inspector', () => { const { result } = renderHook(() => useUiEditorPage('/tmp/ui-editor')); - act(() => { - result.current.editor.replaceState(structuredClone(SELECTABLE_STATE)); + result.current.editor.addDesignImages([ + { + id: 'page', + image: { + metadata: { + name: 'Page', + description: '', + role: 'Page', + slave_to: null, + }, + path: 'assets/page.png', + pixel_size: [320, 180], + pixels_per_unit: 1, + }, + }, + ]); + result.current.editor.addSpriteAssets([ + { + asset_id: 'panel', + metadata: { name: 'Panel', asset_type: '' }, + path: 'assets/panel.png', + pixel_size: [32, 32], + pixels_per_unit: 1, + border: { left: 0, right: 0, top: 0, bottom: 0 }, + }, + ]); + }); + const rootId = result.current.editor.state.ui_trees[0]!.root.id; + act(() => { result.current.selectDesignImage('page'); - result.current.selectNode('page-root'); + result.current.selectNode(rootId); }); + expect(result.current.selectedNodeId).toBe(rootId); - const inspector = render( - createElement(InspectorSidebar, { controller: result.current }), - ); - expect(screen.getByRole('heading', { name: '当前节点' })).toBeTruthy(); - - act(() => { - result.current.selectSprite('button'); - }); - inspector.rerender( - createElement(InspectorSidebar, { controller: result.current }), - ); - - expect(screen.getByRole('heading', { name: '当前素材' })).toBeTruthy(); - expect(screen.queryByRole('heading', { name: '当前节点' })).toBeNull(); + act(() => result.current.selectSprite('panel')); + expect(result.current.selectedNodeId).toBeNull(); + expect(result.current.selectedSpriteId).toBe('panel'); }); }); diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index b72898eac..d724bd47c 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -3,9 +3,8 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { - validateComponentRecognitionPrerequisites, -} from '../src/features/ui-editor/prerequisites'; +import { validateComponentRecognitionPrerequisites } from '../src/features/ui-editor/prerequisites'; +import type { FontAsset } from '../src/features/ui-editor/types/FontAsset'; import type { Node } from '../src/features/ui-editor/types/Node'; import type { SpriteAsset } from '../src/features/ui-editor/types/SpriteAsset'; import type { State } from '../src/features/ui-editor/types/State'; @@ -35,6 +34,22 @@ function sprite(id: string): SpriteAsset { }; } +function font(id: string): FontAsset { + return { + asset_id: id, + metadata: { + family_name: '测试字体', + face_name: 'Regular', + weight: 400, + italic: false, + format: 'TrueType', + source_file_name: 'test.ttf', + }, + path: `assets/fonts/${id}.ttf`, + content_sha256: 'a'.repeat(64), + }; +} + function nodeWithSprite(id: string): Node { return { id, @@ -85,7 +100,9 @@ describe('useUiEditorState', () => { expect(result.current.state.ui_design_images['section-a']).toMatchObject({ metadata: { name: '任务页', role: 'Section', slave_to: 'page-a' }, }); - expect(validateComponentRecognitionPrerequisites(result.current.state)).toEqual([]); + expect( + validateComponentRecognitionPrerequisites(result.current.state), + ).toEqual([]); }); it('adds a batch atomically and rejects duplicates and limits', () => { @@ -173,8 +190,14 @@ describe('useUiEditorState', () => { const initial: State = { ...structuredClone(EMPTY_UI_EDITOR_STATE), ui_design_images: { - page: { ...image('Page'), metadata: { name: 'Page', role: 'Page', slave_to: null } }, - child: { ...image('Child'), metadata: { name: 'Child', role: 'Section', slave_to: 'page' } }, + page: { + ...image('Page'), + metadata: { name: 'Page', role: 'Page', slave_to: null }, + }, + child: { + ...image('Child'), + metadata: { name: 'Child', role: 'Section', slave_to: 'page' }, + }, }, sprite_assets: { panel: sprite('panel') }, ui_trees: [ @@ -206,13 +229,16 @@ describe('useUiEditorState', () => { const { result } = renderHook(() => useUiEditorState(initial)); act(() => { - expect(result.current.removeSpriteAsset('panel', { dryRun: true })).toEqual({ + expect( + result.current.removeSpriteAsset('panel', { dryRun: true }), + ).toEqual({ ok: true, value: { removedResourceCount: 1, removedTreeCount: 0, clearedSlaveToCount: 0, clearedTargetGraphicCount: 1, + clearedFontCount: 0, }, }); }); @@ -225,15 +251,113 @@ describe('useUiEditorState', () => { expect(result.current.state.sprite_assets.panel).toBeUndefined(); expect(result.current.state.ui_trees).toEqual([]); expect(result.current.state.ui_design_images.page).toBeUndefined(); - expect(result.current.state.ui_design_images.child?.metadata.slave_to).toBeNull(); + expect( + result.current.state.ui_design_images.child?.metadata.slave_to, + ).toBeNull(); + }); + + it('merges identical sprite and font resources idempotently and rejects identity conflicts', () => { + const { result } = renderHook(() => useUiEditorState()); + act(() => { + expect(result.current.addSpriteAssets([sprite('panel')])).toEqual({ + ok: true, + value: undefined, + }); + expect(result.current.addFontAssets([font('body')])).toEqual({ + ok: true, + value: undefined, + }); + expect( + result.current.addSpriteAssets([sprite('panel'), sprite('icon')]), + ).toEqual({ ok: true, value: undefined }); + expect( + result.current.addFontAssets([font('body'), font('title')]), + ).toEqual({ ok: true, value: undefined }); + }); + expect(Object.keys(result.current.state.sprite_assets)).toEqual([ + 'panel', + 'icon', + ]); + expect(Object.keys(result.current.state.font_assets)).toEqual([ + 'body', + 'title', + ]); + + const conflicting = font('body'); + conflicting.path = 'assets/fonts/other.ttf'; + act(() => { + expect(result.current.addFontAssets([conflicting])).toEqual({ + ok: false, + reason: 'duplicate', + }); + }); + expect(result.current.state.font_assets.body?.path).toBe( + 'assets/fonts/body.ttf', + ); + }); + + it('dry-runs and clears Text font references without deleting project files', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + font_assets: { body: font('body') }, + ui_design_images: { page: image('Page') }, + ui_trees: [ + { + src_ui_design: 'page', + root: { + ...nodeWithSprite('unused'), + id: 'text-root', + components: [ + { + Text: { + content: '你好', + font: 'body', + font_sizing: { Fixed: 14 }, + color: [255, 255, 255, 255], + alignment: 'UpperLeft', + horizontal_overflow: 'Wrap', + vertical_overflow: 'Truncate', + line_spacing: 1, + }, + }, + ], + }, + }, + ], + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + expect(result.current.removeFontAsset('body', { dryRun: true })).toEqual({ + ok: true, + value: { + removedResourceCount: 1, + removedTreeCount: 0, + clearedSlaveToCount: 0, + clearedTargetGraphicCount: 0, + clearedFontCount: 1, + }, + }); + result.current.removeFontAsset('body', { dryRun: false }); + }); + expect(result.current.state.font_assets.body).toBeUndefined(); + expect(result.current.state.ui_trees[0]?.root.components[0]).toMatchObject({ + Text: { font: null }, + }); }); it('keeps setters local and defers workflow errors to prerequisites', () => { const initial: State = { ...structuredClone(EMPTY_UI_EDITOR_STATE), ui_design_images: { - page: { ...image('Page'), metadata: { name: 'Page', role: 'Page', slave_to: null } }, - child: { ...image('Child'), metadata: { name: 'Child', role: 'Section', slave_to: 'page' } }, + page: { + ...image('Page'), + metadata: { name: 'Page', role: 'Page', slave_to: null }, + }, + child: { + ...image('Child'), + metadata: { name: 'Child', role: 'Section', slave_to: 'page' }, + }, }, }; const { result } = renderHook(() => useUiEditorState(initial)); @@ -242,9 +366,16 @@ describe('useUiEditorState', () => { result.current.setImageRole('page', null); }); - expect(result.current.state.ui_design_images.child?.metadata.slave_to).toBe('page'); - expect(validateComponentRecognitionPrerequisites(result.current.state)).toEqual([ - expect.objectContaining({ code: 'invalid-slave-to', resourceId: 'child' }), + expect(result.current.state.ui_design_images.child?.metadata.slave_to).toBe( + 'page', + ); + expect( + validateComponentRecognitionPrerequisites(result.current.state), + ).toEqual([ + expect.objectContaining({ + code: 'invalid-slave-to', + resourceId: 'child', + }), ]); }); }); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index d4ff922f2..60ffc8793 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,13 @@ # AI 游戏创作智能体 App 实施计划 +## 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 仍只属于当前桌面会话,不新增持久化合同。 + +字体资源发现与 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 条目。 + +候选格式为 TTF、OTF、WOFF 和 WOFF2,但 Rust 安全解析是导入硬门;当前解析依赖不能完整解析的压缩 Web Font 必须拒绝,不能把浏览器可能加载当作验证成功。已登记字体字节只能经字体专用 Tauri 命令读取;命令重新核对 manifest 的 asset ID / 相对路径、普通文件、大小、字体结构与摘要。前端以 `FontAssetId` 派生私有 CSS family,创建 Blob URL 和 `FontFace`,加载成功后加入当前 `document.fonts`,资源变更或卸载时删除 FontFace 并回收 Blob URL。同名 family 不共享浏览器注册名。WebView 加载失败或字体缺少当前文本字形时不阻塞后续阶段,Inspector 显示非阻断提示并回退系统字体;悬空字体 ID 继续由 prerequisite 阻止。`BestFit` 保持现有取最大字号的近似,不在本次字体闭环中扩展为测量算法。 + ## 2026-08-12 Issue #163:子 Agent 澄清回执中转 正式用户对话 Agent 仍固定为 `project-supervisor`;委派专业 Agent 和隔离 child 不得直接调用 `user.input_request`。当 child 缺少会实质改变结果的用户事实时,child 以短小的 `AGC_NEEDS_USER_INPUT_V1` + 结构化 JSON 终态回执交付问题,Runtime 将其作为 `needs-user-input` delivery,而不是 `needs-repair`。