删除临时 UI HTML 测试工具
移除 ui-design-to-html standalone bin 保留 Runtime ui.design.to_html 作为唯一 HTML 生成入口
This commit is contained in:
@@ -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('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
fn fail(message: &str) -> ! {
|
||||
eprintln!("{message}");
|
||||
std::process::exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user