fix(loader): .op render-parity — fixed-height clip, status-bar box, path geometry

A clipped frame with an explicit numeric height now honours that height and
clips overflow (layout_repair no longer lets the root grow to content), matching
Pencil's fixed-height artboards. Status-bar shell strokes are suppressed in the
scene path (width-independent) so they don't paint a stray black frame, and
legacy .op path nodes remap geometry→d so authored paths aren't dropped.
This commit is contained in:
Fini 2026-07-02 21:21:31 +08:00
parent 0730e1ccce
commit fb2ebb62ac
5 changed files with 84 additions and 14 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<PenFill>`.
normalize_fill(map, changed);