diff --git a/crates/op-pen-loader/src/layout_repair.rs b/crates/op-pen-loader/src/layout_repair.rs index e4c64a6c4..ffaa727a0 100644 --- a/crates/op-pen-loader/src/layout_repair.rs +++ b/crates/op-pen-loader/src/layout_repair.rs @@ -569,6 +569,18 @@ fn height_can_expand_to_content(props: &ContainerProps) -> bool { } fn height_can_expand_to_content_or_root(props: &ContainerProps, is_root: bool) -> bool { + // A clipped, explicitly-sized frame must honour its declared height even at + // the root: Pencil clips a fixed-height screen whose content overflows + // rather than growing the frame to fit it. Without this guard the + // `|| is_root` override grew a `height: 900, clip: true` screen to its + // ~950px content height (off-by-50 vs Pencil's clipped baseline). The + // non-root path already refuses via `height_can_expand_to_content`; mirror + // it for the root instead of blanket-allowing expansion. + if matches!(props.height.as_ref(), Some(SizingBehavior::Number(_))) + && props.clip_content == Some(true) + { + return height_can_expand_to_content(props); + } height_can_expand_to_content(props) || is_root } diff --git a/crates/op-pen-loader/src/layout_repair_tests.rs b/crates/op-pen-loader/src/layout_repair_tests.rs index 7a82be138..ad454b232 100644 --- a/crates/op-pen-loader/src/layout_repair_tests.rs +++ b/crates/op-pen-loader/src/layout_repair_tests.rs @@ -188,11 +188,15 @@ fn non_clipped_fixed_height_layout_containers_expand_to_overflowing_children() { } #[test] -fn clipped_vertical_root_expands_when_repaired_content_overflows() { - // Top-level artboards still clip at paint time, but generated legacy roots - // can receive taller repaired children after exact text/layout measurement. - // The root bounds must grow with that repaired stack or the bottom nav is - // clipped out of the exported artboard. +fn clipped_vertical_root_with_fixed_height_clips_overflow_like_pencil() { + // A root that declares an explicit (Number) height AND `clip:true` must + // honour that height and clip the overflow — matching Pencil, which renders + // a fixed-height artboard at exactly its declared size and clips content + // that spills past it (verified against a Pencil `export_nodes` baseline: + // a 900-tall clipped screen exported at 900, not its ~950px content). This + // also makes the root CONSISTENT with the nested case below, where a + // clipped fixed-height container already refuses to grow. `fit_content` + // roots (no Number height) still expand via `height_can_follow_content`. let src = r##"{ "version":"1.0.0","pages":[{"id":"p","name":"P","children":[ {"type":"frame","id":"root","width":200,"height":50,"clip":true, @@ -213,11 +217,17 @@ fn clipped_vertical_root_expands_when_repaired_content_overflows() { }"##; let scene = editor_state_to_layout_scene(&state_from(src)); let root = &scene.pages[0].children[0]; - let after = root.find("after").expect("following child"); + // Children still lay out at their repaired positions (overflowing past 50)… + let after = root.find("after").expect("following child"); assert_eq!(after.bounds.origin.y, 51.0); - assert_eq!(root.bounds.size.y, 61.0); - assert!(root.clip_content, "root must still clip after expanding"); + // …but the root keeps its declared 50px height and clips the overflow, + // rather than growing to the ~61px content height. + assert_eq!(root.bounds.size.y, 50.0); + assert!( + root.clip_content, + "fixed-height clipped root must still clip" + ); } #[test] diff --git a/crates/op-pen-loader/src/layout_scene.rs b/crates/op-pen-loader/src/layout_scene.rs index 434c2724a..37093e5de 100644 --- a/crates/op-pen-loader/src/layout_scene.rs +++ b/crates/op-pen-loader/src/layout_scene.rs @@ -221,11 +221,20 @@ fn node_payload_to_scene( .shader .as_ref() .map(|s| payload_shader_to_scene(s, cum_opacity)), - stroke: node.stroke.as_ref().map(|s| { - let mut st = scene_stroke(s, &node_id, var_table); - st.color = mul_alpha(st.color, cum_opacity); - st - }), + stroke: if is_status_bar_shell_stroke(node) { + // The scene path (editor canvas + render-shots) bypasses the + // adapter's `legacy_payload_repair`, so an iPhone status-bar + // shell ("Time"/"Levels") authored with a no-fill stroke would + // paint a phantom black box around the clock / signal cluster — + // invisible in Pencil. Drop the stroke the same way here. + None + } else { + node.stroke.as_ref().map(|s| { + let mut st = scene_stroke(s, &node_id, var_table); + st.color = mul_alpha(st.color, cum_opacity); + st + }) + }, text: node.text.clone(), text_runs: text_runs_to_scene(&node.text_runs, cum_opacity), font_family: node.font_family.clone(), @@ -472,6 +481,25 @@ fn scene_stroke( } } +/// An iPhone status-bar layout shell ("Time" / "Levels") authored with a +/// stroke but no fill — Pencil paints nothing, so the no-fill stroke (which +/// resolves to opaque black) must not draw a phantom box. Mirrors +/// `legacy_payload_repair::is_legacy_status_bar_shell` on the resolved +/// `NodePayload` the scene path carries (no canonical `PenNode` here). Width +/// is intentionally unconstrained — the shells are `fill_container`, so a +/// wider status bar computes a larger width than a fixed-width sample. +fn is_status_bar_shell_stroke(node: &NodePayload) -> bool { + // A thin status-bar row: the authored 22 px computes to ~22-23 here + // (stroke / rounding), so match a small range rather than an exact 22. + // The no-fill + stroke + non-empty-children gates already exclude the + // inner "Time" text glyph node (which is filled, strokeless, leaf). + node.stroke.is_some() + && node.fill.is_none() + && matches!(node.name.as_str(), "Time" | "Levels") + && !node.children.is_empty() + && (18.0..=28.0).contains(&node.h) +} + /// `[r, g, b, a]` payload colour → shell-core `Color`. Lossless; /// the same conversion `apply_payload` runs on the `Document` path. fn array_to_color(a: [f32; 4]) -> Color { diff --git a/crates/op-pen-loader/src/legacy_payload_repair.rs b/crates/op-pen-loader/src/legacy_payload_repair.rs index 445a3bdc0..093011c84 100644 --- a/crates/op-pen-loader/src/legacy_payload_repair.rs +++ b/crates/op-pen-loader/src/legacy_payload_repair.rs @@ -45,7 +45,13 @@ fn is_legacy_status_bar_shell(frame: &FrameNode, payload: &NodePayload) -> bool return false; } - nearly(payload.w, 100.0) && nearly(payload.h, 22.0) + // Height (~22) + the `Time`/`Levels` name + a no-fill stroke over + // non-empty children pins this to an iPhone status-bar shell. The + // width is NOT constrained: the shells are authored `fill_container`, + // so a wider status bar (pencil-demo's 402-px bar) computes a much + // larger width than the ~100 an earlier fixed-width sample had, and a + // hard width match silently left those shells painting a black frame. + nearly(payload.h, 22.0) } fn is_legacy_battery_wrapper(frame: &FrameNode, payload: &NodePayload) -> bool { diff --git a/crates/op-pen-loader/src/payload.rs b/crates/op-pen-loader/src/payload.rs index 9acd96b31..b6e333b6d 100644 --- a/crates/op-pen-loader/src/payload.rs +++ b/crates/op-pen-loader/src/payload.rs @@ -528,6 +528,7 @@ fn normalize_node_value( let is_image = kind == Some("image"); let is_icon = kind == Some("icon"); let is_prompt = kind == Some("prompt"); + let is_path = kind == Some("path"); if is_image && !map.contains_key("src") { map.insert("src".to_string(), Value::String(String::new())); *changed = true; @@ -554,6 +555,19 @@ fn normalize_node_value( } *changed = true; } + // Pencil stores a `path` node's flattened outline as an SVG + // string under `geometry`; jian's `PathNode` reads it from + // `d`. Without this remap the geometry is dropped and the + // path renders empty — status-bar icons, logos, and any + // vector art authored as a path vanish. + if is_path { + if let Some(geom) = map.get("geometry").cloned() { + if geom.is_string() && !map.contains_key("d") { + map.insert("d".to_string(), geom); + *changed = true; + } + } + } // A PenNode `fill` written as a bare string / single object / // legacy `type:"color"` array → canonical `Vec`. normalize_fill(map, changed);