fix(ai): preserve generated layout parents
This commit is contained in:
parent
ec553017b1
commit
f0955609d7
|
|
@ -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<i32> = {
|
||||
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| {
|
||||
|
|
|
|||
116
crates/op-orchestrator/src/cleanup_layout.rs
Normal file
116
crates/op-orchestrator/src/cleanup_layout.rs
Normal file
|
|
@ -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<i32> {
|
||||
let height = container_content_height(root)?;
|
||||
(height > 0.0).then_some(height.round() as i32)
|
||||
}
|
||||
|
||||
fn intrinsic_height(node: &PenNode) -> Option<f64> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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": <string> }]` 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<String, serde_json::Value>) {
|
||||
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<String, serde_json::Value>) {
|
||||
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<f64> {
|
||||
match value {
|
||||
serde_json::Value::Number(n) => n.as_f64(),
|
||||
serde_json::Value::String(s) => s
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.or_else(|| resolve_numeric_design_token(&s)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn string_from_json(value: serde_json::Value) -> Option<String> {
|
||||
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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 个
|
||||
|
|
|
|||
Loading…
Reference in a new issue