删除临时 UI HTML 测试工具

移除 ui-design-to-html standalone bin

保留 Runtime ui.design.to_html 作为唯一 HTML 生成入口
This commit is contained in:
2026-08-31 19:07:56 +08:00
parent 7915d58038
commit 56be007ec9
@@ -1,399 +0,0 @@
use maud::{html, PreEscaped};
use serde_json::Value;
use std::env;
use std::fmt::Write as _;
use std::fs;
fn main() {
let args = env::args().skip(1).collect::<Vec<_>>();
if args.len() != 2 {
eprintln!("用法:ui-design-to-html <ui-state.json> <output.html>");
std::process::exit(2);
}
let input = fs::read_to_string(&args[0])
.unwrap_or_else(|error| fail(&format!("读取输入失败:{error}")));
let document: Value = serde_json::from_str(&input)
.unwrap_or_else(|error| fail(&format!("解析 UI JSON 失败:{error}")));
let state = document.get("state").unwrap_or(&document);
let html = render_state(state).unwrap_or_else(|error| fail(&error));
fs::write(&args[1], html).unwrap_or_else(|error| fail(&format!("写入 HTML 失败:{error}")));
}
fn render_state(state: &Value) -> Result<String, String> {
let trees = state
.get("ui_trees")
.and_then(Value::as_array)
.ok_or("State 缺少 ui_trees")?;
let mut body = String::new();
for (index, tree) in trees.iter().enumerate() {
if index > 0 {
body.push_str("\n<!-- -------------------- -->\n");
}
body.push_str(&render_tree(state, tree)?);
}
Ok(html! {
(PreEscaped("<!doctype html>"))
html {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
style { (PreEscaped("html,body{margin:0;width:100%;min-height:100%;}body{overflow:auto;}[data-ui-tree]{position:relative;width:100%;min-height:0;overflow:hidden;}:root{--ui-scale:1;}")) }
}
body { (PreEscaped(body)) }
}
}.into_string())
}
fn render_tree(state: &Value, tree: &Value) -> Result<String, String> {
let source = tree
.get("src_ui_design")
.and_then(Value::as_str)
.ok_or("UITree 缺少 src_ui_design")?;
let image = state
.get("ui_design_images")
.and_then(|images| images.get(source))
.ok_or_else(|| format!("缺少设计图资源:{source}"))?;
let width = logical_dimension(image, 0)?;
let height = logical_dimension(image, 1)?;
let root = tree.get("root").ok_or("UITree 缺少 root")?;
let mut content = String::new();
let tree_metadata =
serde_json::json!({"srcUiDesign": source, "width": width, "height": height});
write!(
&mut content,
"<!-- genarrative-ui-tree: {} -->",
comment_safe(&tree_metadata.to_string())
)
.unwrap();
write!(&mut content, "<div data-ui-tree=\"{}\" style=\"position:relative;width:100%;height:auto;min-height:0;aspect-ratio:{} / {};--ui-design-width:{}px;--ui-design-height:{}px;\">", html_escape(source), width, height, width, height).unwrap();
content.push_str(&render_node(state, root, false)?);
content.push_str("</div>");
Ok(content)
}
fn render_node(state: &Value, node: &Value, in_container: bool) -> Result<String, String> {
let id = node
.get("id")
.and_then(Value::as_str)
.ok_or("Node 缺少 id")?;
let metadata = node.get("metadata").ok_or("Node 缺少 metadata")?;
let name = metadata
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let description = metadata
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let layout = node.get("layout").ok_or("Node 缺少 layout")?;
let node_metadata = serde_json::json!({"nodeId": id, "name": name, "description": description});
let mut output = format!(
"<!-- genarrative-ui-node: {} -->",
comment_safe(&node_metadata.to_string())
);
if node.get("children_display_mode").and_then(Value::as_str) == Some("Exclusive") {
output.push_str("<!-- genarrative-ui-node-group: childrenDisplayMode=Exclusive; all children retained -->");
}
let style = node_style(layout, in_container)?;
let style = format!("{style}border:0;outline:0;background:transparent;overflow:visible;");
write!(
&mut output,
"<div data-node-id=\"{}\" style=\"{}\">",
html_escape(id),
style
)
.unwrap();
if let Some(components) = node.get("components").and_then(Value::as_array) {
for component in components {
output.push_str(&render_component(state, component)?);
}
}
let child_container = layout.get("container").is_some_and(|value| value != "None");
if let Some(children) = node.get("children").and_then(Value::as_array) {
for child in children {
output.push_str(&render_node(state, child, child_container)?);
}
}
output.push_str("</div>");
Ok(output)
}
fn render_component(state: &Value, component: &Value) -> Result<String, String> {
if let Some(text) = component.get("Text") {
let content = text
.get("content")
.and_then(Value::as_str)
.unwrap_or_default();
let size = text
.get("font_sizing")
.and_then(|sizing| {
sizing
.get("Fixed")
.or_else(|| sizing.get("BestFit").and_then(|range| range.get("min")))
})
.and_then(Value::as_u64)
.unwrap_or(14);
let alignment = text
.get("alignment")
.and_then(Value::as_str)
.unwrap_or("UpperLeft");
let horizontal = if alignment.ends_with("Center") {
"center"
} else if alignment.ends_with("Right") {
"right"
} else {
"left"
};
let vertical = if alignment.starts_with("Middle") {
"center"
} else if alignment.starts_with("Lower") {
"flex-end"
} else {
"flex-start"
};
let color = text
.get("color")
.and_then(Value::as_array)
.map(|v| {
[
v.first().and_then(Value::as_u64).unwrap_or(255),
v.get(1).and_then(Value::as_u64).unwrap_or(255),
v.get(2).and_then(Value::as_u64).unwrap_or(255),
v.get(3).and_then(Value::as_u64).unwrap_or(255),
]
})
.unwrap_or([255, 255, 255, 255]);
let white_space =
if text.get("horizontal_overflow").and_then(Value::as_str) == Some("Overflow") {
"nowrap"
} else {
"normal"
};
let overflow = if text.get("vertical_overflow").and_then(Value::as_str) == Some("Overflow")
{
"visible"
} else {
"hidden"
};
let overflow_wrap = if white_space == "normal" {
"anywhere"
} else {
"normal"
};
let line_spacing = text
.get("line_spacing")
.and_then(Value::as_f64)
.unwrap_or(1.0);
let (font_weight, font_style) = match text.get("font_style").and_then(Value::as_str) {
Some("Bold") => (700, "normal"),
Some("Italic") => (400, "italic"),
Some("BoldItalic") => (700, "italic"),
_ => (400, "normal"),
};
let style = format!("position:absolute;inset:0;display:flex;width:100%;height:100%;box-sizing:border-box;color:rgba({},{},{},{});font-size:calc({size}px * var(--ui-scale, 1));font-weight:{font_weight};font-style:{font_style};align-items:{vertical};justify-content:{};text-align:{horizontal};line-height:{line_spacing};white-space:{white_space};overflow:{overflow};overflow-wrap:{overflow_wrap};", color[0], color[1], color[2], color[3] as f32 / 255.0, if horizontal == "left" { "flex-start" } else if horizontal == "center" { "center" } else { "flex-end" });
return Ok(
html! { div data-component-kind="Text" style=(style) { (content) } }.into_string(),
);
}
if let Some(image) = component.get("Image") {
let target = image
.get("target_graphic")
.and_then(Value::as_str)
.ok_or("Image 缺少 target_graphic")?;
let sprite = state
.get("sprite_assets")
.and_then(|sprites| sprites.get(target))
.ok_or_else(|| format!("缺少 Sprite 资源:{target}"))?;
let path = sprite
.get("path")
.and_then(Value::as_str)
.ok_or("Sprite 缺少 path")?;
if path.trim().is_empty()
|| path.starts_with('/')
|| path.split('/').any(|part| part == "..")
|| path.chars().any(|character| {
character.is_control() || matches!(character, '\'' | '"' | '`' | '(' | ')')
})
{
return Err(format!("资源路径无效:{path}"));
}
let src = format!("/{path}");
let image_type = image.get("image_type").unwrap_or(&Value::Null);
if let Some(tiled) = image_type.get("Tiled") {
let multiplier = tiled
.get("pixels_per_unit_multiplier")
.and_then(Value::as_f64)
.unwrap_or(1.0);
let w = logical_sprite_dimension(sprite, 0, multiplier)?;
let h = logical_sprite_dimension(sprite, 1, multiplier)?;
let style = format!("position:absolute;inset:0;overflow:hidden;background-image:url(\"{src}\");background-repeat:repeat;background-position:top left;background-size:{w}px {h}px;");
return Ok(html! { div data-component-kind="Image" style=(style) {} }.into_string());
}
let mut image_style = "width:100%;height:100%;display:block;object-fit:fill;".to_string();
if let Some(simple) = image_type.get("Simple") {
if simple
.get("preserve_aspect")
.and_then(Value::as_bool)
.unwrap_or(false)
{
image_style = image_style.replace("fill", "contain");
}
}
if let Some(filled) = image_type.get("Filled") {
let amount = filled
.get("amount")
.and_then(Value::as_f64)
.unwrap_or(1.0)
.clamp(0.0, 1.0);
if amount < 1.0 {
image_style.push_str(&format!(
"clip-path:inset(0 {}% 0 0);",
(1.0 - amount) * 100.0
));
}
}
return Ok(html! { div data-component-kind="Image" style="position:absolute;inset:0;overflow:hidden;" { img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style); } }.into_string());
}
Err("未知 UI 组件类型".to_string())
}
fn node_style(layout: &Value, in_container: bool) -> Result<String, String> {
let transform = layout
.get("transform")
.ok_or("ControlLayout 缺少 transform")?;
let amin = pair(transform, "anchor_min")?;
let amax = pair(transform, "anchor_max")?;
let omin = pair(transform, "offset_min")?;
let omax = pair(transform, "offset_max")?;
if amin[0] > amax[0] || amin[1] > amax[1] {
return Err("Transform anchors must be ordered".to_string());
}
let mut style = if in_container {
"position:relative;".to_string()
} else {
format!(
"position:absolute;left:{};top:{};right:{};bottom:{};",
css_length(amin[0] * 100.0, omin[0])?,
css_length(amin[1] * 100.0, omin[1])?,
css_length((1.0 - amax[0]) * 100.0, -omax[0])?,
css_length((1.0 - amax[1]) * 100.0, -omax[1])?
)
};
if let Some(container) = layout.get("container") {
style.push_str(&container_css(container));
}
Ok(style)
}
fn container_css(container: &Value) -> String {
if container == "None" {
return String::new();
}
if let Some(props) = container.get("HBox") {
return format!(
"display:flex;flex-direction:row;gap:calc({}px * var(--ui-scale, 1));",
number(props.get("separation"))
);
}
if let Some(props) = container.get("VBox") {
return format!(
"display:flex;flex-direction:column;gap:calc({}px * var(--ui-scale, 1));",
number(props.get("separation"))
);
}
if let Some(props) = container.get("Grid") {
return format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:calc({}px * var(--ui-scale, 1));row-gap:calc({}px * var(--ui-scale, 1));", props.get("columns").and_then(Value::as_u64).unwrap_or(1), number(props.get("h_separation")), number(props.get("v_separation")));
}
if let Some(props) = container.get("Margin") {
return format!(
"display:grid;padding:calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1));",
number(props.get("margin_top")),
number(props.get("margin_right")),
number(props.get("margin_bottom")),
number(props.get("margin_left"))
);
}
"display:grid;place-items:center;".to_string()
}
fn css_length(percent: f64, offset: f64) -> Result<String, String> {
if !percent.is_finite() || !offset.is_finite() {
return Err("Transform 包含非有限数值".to_string());
}
if percent == 0.0 {
return Ok(format!("calc({offset}px * var(--ui-scale, 1))"));
}
if offset == 0.0 {
return Ok(format!("{percent}%"));
}
Ok(format!(
"calc({percent}% {} {}px * var(--ui-scale, 1))",
if offset < 0.0 { '-' } else { '+' },
offset.abs()
))
}
fn pair(value: &Value, field: &str) -> Result<[f64; 2], String> {
let values = value
.get(field)
.and_then(Value::as_array)
.ok_or_else(|| format!("Transform 缺少 {field}"))?;
if values.len() != 2 {
return Err(format!("Transform.{field} 不是二元数组"));
}
Ok([
values[0].as_f64().ok_or("Transform 数值无效")?,
values[1].as_f64().ok_or("Transform 数值无效")?,
])
}
fn logical_dimension(image: &Value, index: usize) -> Result<f64, String> {
let pixels = image
.get("pixel_size")
.and_then(Value::as_array)
.and_then(|v| v.get(index))
.and_then(Value::as_f64)
.ok_or("设计图 pixel_size 无效")?;
let ppu = image
.get("pixels_per_unit")
.and_then(Value::as_f64)
.ok_or("设计图 pixels_per_unit 无效")?;
if pixels <= 0.0 || ppu <= 0.0 {
return Err("设计图尺寸无效".to_string());
}
Ok(pixels / ppu)
}
fn logical_sprite_dimension(sprite: &Value, index: usize, multiplier: f64) -> Result<f64, String> {
let pixels = sprite
.get("pixel_size")
.and_then(Value::as_array)
.and_then(|v| v.get(index))
.and_then(Value::as_f64)
.ok_or("Sprite pixel_size 无效")?;
let ppu = sprite
.get("pixels_per_unit")
.and_then(Value::as_f64)
.ok_or("Sprite pixels_per_unit 无效")?;
if pixels <= 0.0 || ppu <= 0.0 || multiplier <= 0.0 {
return Err("Sprite 尺寸无效".to_string());
}
Ok(pixels / (ppu * multiplier))
}
fn number(value: Option<&Value>) -> String {
value
.and_then(Value::as_f64)
.map(|v| v.to_string())
.unwrap_or_else(|| "0".to_string())
}
fn comment_safe(value: &str) -> String {
value.replace("--", "- -")
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn fail(message: &str) -> ! {
eprintln!("{message}");
std::process::exit(1)
}