fix(ai): preserve generated layout parents

This commit is contained in:
Fini 2026-06-07 16:15:30 +08:00
parent ec553017b1
commit f0955609d7
6 changed files with 349 additions and 23 deletions

View file

@ -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| {

View 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),
}
}

View file

@ -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();

View file

@ -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;

View file

@ -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",
];

View file

@ -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 = &section.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 个