From f0955609d7bebb4fcc633eba1d0abfa3a0d96fe9 Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 7 Jun 2026 16:15:30 +0800 Subject: [PATCH] fix(ai): preserve generated layout parents --- crates/op-orchestrator/src/cleanup.rs | 16 +- crates/op-orchestrator/src/cleanup_layout.rs | 116 +++++++++++++++ crates/op-orchestrator/src/cleanup_tests.rs | 47 ++++++ crates/op-orchestrator/src/lib.rs | 1 + crates/op-orchestrator/src/parse.rs | 145 +++++++++++++++++-- crates/op-orchestrator/src/parse_tests.rs | 47 ++++++ 6 files changed, 349 insertions(+), 23 deletions(-) create mode 100644 crates/op-orchestrator/src/cleanup_layout.rs diff --git a/crates/op-orchestrator/src/cleanup.rs b/crates/op-orchestrator/src/cleanup.rs index f2b798873..443610764 100644 --- a/crates/op-orchestrator/src/cleanup.rs +++ b/crates/op-orchestrator/src/cleanup.rs @@ -19,6 +19,7 @@ //! Roll back plan-derived variables only when NOTHING survived across all roots. //! Port of `orchestrator.ts:1101-1158`. +use crate::cleanup_layout::root_content_height; use crate::cleanup_typography::repair_overbold_text_hierarchy; use crate::plan::OrchestratorPlan; use crate::types::{DocSink, OrchestratorError, SubtaskOutcome}; @@ -227,25 +228,14 @@ struct MobileSectionRepairs { } /// Pass ③:根 frame 高度自适应到内容。把根 frame 的高度设为其 -/// 直接子节点的像素高度之和(无显式像素高度的子节点跳过)。 +/// 直接子节点的内容高度之和;`fit_content` 容器会按子节点估算。 /// 对齐 TS `adjustRootFrameHeightToContent`。 fn adjust_root_height_to_content(sink: &mut dyn DocSink, root_id: &str) { let total: Option = { let Some(root) = find_root(sink.state(), root_id) else { return; }; - let Some(children) = root.children() else { - return; - }; - if children.is_empty() { - return; - } - let sum: f64 = children.iter().filter_map(|c| c.height_px()).sum(); - if sum > 0.0 { - Some(sum.round() as i32) - } else { - None - } + root_content_height(root) }; let current_height = find_root(sink.state(), root_id).and_then(|root| root.height_px()); if let Some(height) = total.filter(|height| { diff --git a/crates/op-orchestrator/src/cleanup_layout.rs b/crates/op-orchestrator/src/cleanup_layout.rs new file mode 100644 index 000000000..c6f3b7a67 --- /dev/null +++ b/crates/op-orchestrator/src/cleanup_layout.rs @@ -0,0 +1,116 @@ +//! Layout estimators used by cleanup passes. + +use jian_ops_schema::node::{ + ContainerProps, LayoutMode, NumberOrExpression, Padding, PenNode, TextContent, TextNode, +}; +use op_editor_core::PenNodeExt; + +pub(crate) fn root_content_height(root: &PenNode) -> Option { + let height = container_content_height(root)?; + (height > 0.0).then_some(height.round() as i32) +} + +fn intrinsic_height(node: &PenNode) -> Option { + if let Some(height) = node.height_px().filter(|height| *height > 0.0) { + return Some(height); + } + + match node { + PenNode::Frame(_) | PenNode::Group(_) | PenNode::Rectangle(_) => { + container_content_height(node) + } + PenNode::Ref(n) => stacked_children_height(n.children.as_deref(), 0.0, 0.0), + PenNode::Text(text) => Some(estimated_text_height(text)), + PenNode::IconFont(_) => node.width_px().or(Some(24.0)), + _ => None, + } +} + +fn container_content_height(node: &PenNode) -> Option { + let children = node.children()?; + if children.is_empty() { + return None; + } + let padding_y = container_props(node).map(padding_y).unwrap_or(0.0); + let gap = container_props(node) + .and_then(|props| props.gap.as_ref()) + .and_then(number_value) + .unwrap_or(0.0); + let layout = container_props(node).and_then(|props| props.layout.as_ref()); + + match layout { + Some(LayoutMode::Horizontal) => children + .iter() + .filter_map(intrinsic_height) + .reduce(f64::max) + .map(|height| height + padding_y), + _ => stacked_children_height(Some(children.as_slice()), gap, padding_y), + } +} + +fn stacked_children_height(children: Option<&[PenNode]>, gap: f64, padding_y: f64) -> Option { + let children = children?; + if children.is_empty() { + return None; + } + + let mut total = padding_y; + let mut measured = 0usize; + for child in children { + let Some(height) = intrinsic_height(child) else { + continue; + }; + if measured > 0 { + total += gap; + } + total += height; + measured += 1; + } + (measured > 0).then_some(total) +} + +fn container_props(node: &PenNode) -> Option<&ContainerProps> { + match node { + PenNode::Frame(n) => Some(&n.container), + PenNode::Group(n) => Some(&n.container), + PenNode::Rectangle(n) => Some(&n.container), + _ => None, + } +} + +fn padding_y(props: &ContainerProps) -> f64 { + match props.padding.as_ref() { + Some(Padding::Uniform(v)) => v * 2.0, + Some(Padding::XY([y, _])) => y * 2.0, + Some(Padding::LtrB([top, _, bottom, _])) => top + bottom, + Some(Padding::Expression(_)) | None => 0.0, + } +} + +fn number_value(value: &NumberOrExpression) -> Option { + match value { + NumberOrExpression::Number(v) => Some(*v), + NumberOrExpression::Expression(_) => None, + } +} + +fn estimated_text_height(text: &TextNode) -> f64 { + let font_size = text.font_size.unwrap_or(14.0).max(1.0); + let line_height = match text.line_height { + Some(value) if value > 4.0 => value, + Some(value) if value > 0.0 => font_size * value, + _ => font_size * 1.25, + }; + line_height * text_line_count(&text.content) as f64 +} + +fn text_line_count(content: &TextContent) -> usize { + match content { + TextContent::Plain(text) => text.lines().count().max(1), + TextContent::Styled(segments) => segments + .iter() + .flat_map(|segment| segment.text.lines()) + .count() + .max(1), + } +} diff --git a/crates/op-orchestrator/src/cleanup_tests.rs b/crates/op-orchestrator/src/cleanup_tests.rs index 97f60a32c..da0d25d56 100644 --- a/crates/op-orchestrator/src/cleanup_tests.rs +++ b/crates/op-orchestrator/src/cleanup_tests.rs @@ -142,6 +142,53 @@ fn cleanup_does_not_shrink_fixed_mobile_root_to_partial_child_sum() { assert_eq!(root.height_px(), Some(844.0)); } +#[test] +fn cleanup_expands_zero_height_desktop_root_from_fit_content_children() { + let mut sink = VecDocSink::new(); + let tree: PenNode = serde_json::from_value(json!({ + "type": "frame", + "id": "root", + "name": "Dashboard", + "width": 1200, + "height": 0, + "layout": "vertical", + "fill": [{ "type": "solid", "color": "#FFFFFF" }], + "children": [ + { + "type": "frame", + "id": "section", + "name": "Fit Content Section", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "gap": 12, + "children": [ + {"type": "frame", "id": "header", "width": "fill_container", "height": 64}, + {"type": "frame", "id": "chart", "width": "fill_container", "height": 240} + ] + } + ] + })) + .expect("desktop root json"); + sink.state.apply(EditorCommand::InsertSubtree { + nodes: vec![tree], + parent_id: NodeId::NONE, + page_id: None, + }); + let root_id = sink.state.active_children()[0].id_str().to_string(); + sink.applied.clear(); + + run_cleanup_passes(&mut sink, &plan(), &[&root_id]); + + let root = sink + .state + .active_children() + .iter() + .find(|n| n.id_str() == root_id) + .expect("root survives cleanup"); + assert_eq!(root.height_px(), Some(316.0)); +} + #[test] fn cleanup_recolors_safe_dark_bottom_nav_on_light_mobile_root() { let mut sink = VecDocSink::new(); diff --git a/crates/op-orchestrator/src/lib.rs b/crates/op-orchestrator/src/lib.rs index a0dcc40ba..5828552c8 100644 --- a/crates/op-orchestrator/src/lib.rs +++ b/crates/op-orchestrator/src/lib.rs @@ -33,6 +33,7 @@ pub mod variables; pub mod append; pub mod cleanup; +pub(crate) mod cleanup_layout; pub(crate) mod cleanup_typography; pub mod concurrent; pub mod prompt; diff --git a/crates/op-orchestrator/src/parse.rs b/crates/op-orchestrator/src/parse.rs index e14cc5e95..251a4f4cf 100644 --- a/crates/op-orchestrator/src/parse.rs +++ b/crates/op-orchestrator/src/parse.rs @@ -318,17 +318,14 @@ fn normalize_generated_node_json(value: &mut serde_json::Value) { object.insert("src".into(), serde_json::Value::String(String::new())); } - // `fill` given as a bare color string/ref → wrap into the canonical - // `[{ "type":"solid", "color": }]` sequence. PenNode's fill - // is a sequence; models commonly shorthand it as a plain color - // (实测方舟 `"fill":"$color-success-text"` 否则整 root 报废)。 - if let Some(serde_json::Value::String(color)) = object.get("fill") { - let color = color.clone(); - object.insert( - "fill".into(), - serde_json::json!([{ "type": "solid", "color": color }]), - ); + // `fill` given as a bare color/ref or one fill object → wrap into + // the canonical fill array. PenNode's fill is a sequence; models + // commonly shorthand it (实测方舟/DeepSeek 否则整 root 报废)。 + if let Some(fill) = object.get_mut("fill") { + normalize_fill_json(fill); } + normalize_stroke_json(object); + normalize_layout_enum_json(object); for (key, child) in object.iter_mut() { // 数值型设计 token(`$type-*-size` 等)只在**数值字段**上就地解析 @@ -360,6 +357,132 @@ fn normalize_generated_node_json(value: &mut serde_json::Value) { } } +fn normalize_layout_enum_json(object: &mut serde_json::Map) { + if let Some(serde_json::Value::String(layout)) = object.get_mut("layout") { + if let Some(normalized) = normalize_layout_mode(layout) { + *layout = normalized.to_string(); + } + } + if let Some(serde_json::Value::String(justify)) = object.get_mut("justifyContent") { + if let Some(normalized) = normalize_justify_content(justify) { + *justify = normalized.to_string(); + } + } +} + +fn normalize_layout_mode(value: &str) -> Option<&'static str> { + match value.trim().to_ascii_lowercase().as_str() { + "row" | "rows" | "hstack" | "horizontal" => Some("horizontal"), + "column" | "columns" | "vstack" | "vertical" => Some("vertical"), + "none" => Some("none"), + _ => None, + } +} + +fn normalize_justify_content(value: &str) -> Option<&'static str> { + match value.trim().to_ascii_lowercase().as_str() { + "start" | "flex-start" | "left" | "top" => Some("start"), + "center" | "middle" => Some("center"), + "end" | "flex-end" | "right" | "bottom" => Some("end"), + "space_between" | "space-between" | "space between" => Some("space_between"), + "space_around" | "space-around" | "space around" => Some("space_around"), + "space_evenly" | "space-evenly" | "space evenly" => Some("space_around"), + _ => None, + } +} + +fn normalize_stroke_json(object: &mut serde_json::Map) { + let stroke_width = object + .remove("strokeWidth") + .or_else(|| object.remove("stroke-width")) + .and_then(number_from_json); + let Some(mut stroke) = object.remove("stroke") else { + return; + }; + + if let serde_json::Value::Array(mut items) = stroke { + stroke = if items.is_empty() { + serde_json::Value::Null + } else { + items.remove(0) + }; + } + + let normalized = match stroke { + serde_json::Value::String(color) => serde_json::json!({ + "thickness": stroke_width.unwrap_or(1.0), + "fill": [{ "type": "solid", "color": color }] + }), + serde_json::Value::Object(mut stroke_obj) => { + let color = stroke_obj + .remove("color") + .and_then(string_from_json) + .or_else(|| { + stroke_obj.get("type").and_then(|ty| { + (ty.as_str() == Some("solid")).then(|| "#000000".to_string()) + }) + }); + stroke_obj.remove("type"); + if let Some(fill) = stroke_obj.get_mut("fill") { + normalize_fill_json(fill); + } else if let Some(color) = color { + stroke_obj.insert( + "fill".into(), + serde_json::json!([{ "type": "solid", "color": color }]), + ); + } + if !stroke_obj.contains_key("thickness") { + stroke_obj.insert( + "thickness".into(), + serde_json::json!(stroke_width.unwrap_or(1.0)), + ); + } + serde_json::Value::Object(stroke_obj) + } + _ => return, + }; + + object.insert("stroke".into(), normalized); +} + +fn normalize_fill_json(fill: &mut serde_json::Value) { + match fill { + serde_json::Value::String(color) => { + *fill = serde_json::json!([{ "type": "solid", "color": color.clone() }]); + } + serde_json::Value::Object(_) => { + let single = std::mem::take(fill); + *fill = serde_json::Value::Array(vec![single]); + } + serde_json::Value::Array(items) => { + for item in items { + if let serde_json::Value::String(color) = item { + *item = serde_json::json!({ "type": "solid", "color": color.clone() }); + } + } + } + _ => {} + } +} + +fn number_from_json(value: serde_json::Value) -> Option { + match value { + serde_json::Value::Number(n) => n.as_f64(), + serde_json::Value::String(s) => s + .parse::() + .ok() + .or_else(|| resolve_numeric_design_token(&s)), + _ => None, + } +} + +fn string_from_json(value: serde_json::Value) -> Option { + match value { + serde_json::Value::String(s) if !s.trim().is_empty() => Some(s), + _ => None, + } +} + /// 接受数值型设计 token 解析的字段(canonical PenNode 里是 f64 的几何 / 排版 /// 字段)。其他字段(content / name / color / iconFontName / …)里的 token 串 /// 保持原样,不被改写成数字。 @@ -371,6 +494,8 @@ const NUMERIC_TOKEN_FIELDS: &[&str] = &[ "gap", "padding", "cornerRadius", + "thickness", + "strokeWidth", "width", "height", ]; diff --git a/crates/op-orchestrator/src/parse_tests.rs b/crates/op-orchestrator/src/parse_tests.rs index 0b3a82aaf..de71d9374 100644 --- a/crates/op-orchestrator/src/parse_tests.rs +++ b/crates/op-orchestrator/src/parse_tests.rs @@ -220,6 +220,53 @@ fn normalize_resolves_numeric_token_and_wraps_bare_fill() { .expect("normalized node must deserialize into canonical PenNode"); } +#[test] +fn parse_nodes_renest_keeps_parent_with_stroke_shorthand() { + // DeepSeek/Ark often emit a card parent with `stroke:{type:"solid",color}` + // plus child nodes linked by `_parent`. The parent must be normalized + // instead of dropped; otherwise its valid children get promoted to root and + // render outside the card row. + let text = r##"[ + { "type": "frame", "id": "section", "name": "KPI Cards Section", "width": "fill_container", "height": "fit_content", "layout": "vertical", "_parent": null }, + { "type": "frame", "id": "row", "name": "Card Row", "width": "fill_container", "height": "fit_content", "layout": "horizontal", "gap": 20, "_parent": "section" }, + { "type": "frame", "id": "card", "name": "Revenue Card", "width": "fill_container", "height": "fit_content", "layout": "vertical", "padding": [16, 16], "fill": "#FFFFFF", "stroke": { "type": "solid", "color": "#E2E8F0" }, "strokeWidth": 1, "_parent": "row" }, + { "type": "frame", "id": "top", "name": "Top Row", "width": "fill_container", "height": "fit_content", "layout": "horizontal", "_parent": "card" }, + { "type": "text", "id": "value", "name": "Metric Value", "content": "$45,231", "fontSize": 28, "_parent": "card" } + ]"##; + + let nodes = parse_nodes(text).expect("stroke shorthand parent should be normalized"); + + assert_eq!(nodes.len(), 1); + let section = &nodes[0]; + let row = §ion.children().expect("section children")[0]; + let card = &row.children().expect("row children")[0]; + assert_eq!(card.id_str(), "card"); + assert_eq!(card.children().expect("card children").len(), 2); + assert_eq!(crate::cleanup::count_descendants(section), 4); +} + +#[test] +fn parse_nodes_renest_normalizes_css_justify_content() { + // Models often use CSS spelling for justifyContent. Rust's canonical + // schema stores snake_case; without normalization the chart row parent is + // dropped and its bars become root siblings. + let text = r##"[ + { "type": "frame", "id": "chart", "name": "Chart", "width": "fill_container", "height": "fit_content", "layout": "vertical", "_parent": null }, + { "type": "frame", "id": "bars", "name": "Chart Bars", "width": "fill_container", "height": 160, "layout": "horizontal", "justifyContent": "space-between", "alignItems": "flex-end", "_parent": "chart" }, + { "type": "frame", "id": "mon", "name": "Mon Column", "width": "fill_container", "height": "fit_content", "layout": "vertical", "_parent": "bars" }, + { "type": "frame", "id": "tue", "name": "Tue Column", "width": "fill_container", "height": "fit_content", "layout": "vertical", "_parent": "bars" } + ]"##; + + let nodes = parse_nodes(text).expect("CSS justifyContent should be normalized"); + + assert_eq!(nodes.len(), 1); + let chart = &nodes[0]; + let bars = &chart.children().expect("chart children")[0]; + assert_eq!(bars.id_str(), "bars"); + assert_eq!(bars.children().expect("bars children").len(), 2); + assert_eq!(crate::cleanup::count_descendants(chart), 3); +} + #[test] fn parse_nodes_renests_flat_parent_array() { // 扁平 `_parent` 数组(M2.7 形态):root + 一个横向 row + row 的 3 个