From bf4a741d8ed5eadedc9b8f0440cca680954cdffc Mon Sep 17 00:00:00 2001 From: Fini Date: Thu, 2 Jul 2026 21:21:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(loader):=20mesh/shader=20fills=20+=20.pen?= =?UTF-8?q?=E2=86=92.op=20legacy=20normalize=20hardening=20+=20pen2op=20bi?= =?UTF-8?q?n=20+=20design-library=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps jian submodule for mesh-gradient/shader PenFill variants. normalize_legacy_doc gains 8 .pen-compat repairs (stroke-fill string, gradient fill, icon/prompt nodes, $ref cornerRadius, single-object fill). New pen2op bin converts Pencil .pen→.op. library.rs merges a .lib.op's reusable masters+variables into a working doc. --- crates/op-pen-loader/src/bin/pen2op.rs | 95 +++++ crates/op-pen-loader/src/layout_scene.rs | 56 ++- .../op-pen-loader/src/layout_scene_tests.rs | 97 +++++ crates/op-pen-loader/src/lib.rs | 2 + crates/op-pen-loader/src/library.rs | 236 +++++++++++ crates/op-pen-loader/src/payload.rs | 388 +++++++++++++++++- crates/op-pen-loader/src/style_payload.rs | 121 +++++- vendor/jian | 2 +- 8 files changed, 976 insertions(+), 21 deletions(-) create mode 100644 crates/op-pen-loader/src/bin/pen2op.rs create mode 100644 crates/op-pen-loader/src/library.rs diff --git a/crates/op-pen-loader/src/bin/pen2op.rs b/crates/op-pen-loader/src/bin/pen2op.rs new file mode 100644 index 000000000..eeec7a8cf --- /dev/null +++ b/crates/op-pen-loader/src/bin/pen2op.rs @@ -0,0 +1,95 @@ +//! `pen2op` — convert a plaintext canonical `.pen` (or legacy `.op`) file into +//! a current-schema `.op` file by routing it THROUGH the canonical +//! `load_canonical` + `normalize_legacy_doc` path the desktop uses to open +//! files. A naive rename does NOT work: the normalize step (version 2.x → +//! rewrite, image nodes missing `src`, `fill`-as-string, etc.) is required for +//! the file to load in current OpenPencil. +//! +//! Usage: `pen2op ` +//! +//! The output is `serde_json::to_string_pretty(&result.value)` — the same +//! canonical-PenDocument serialize path `op-smoke` uses to dump `state.doc`. + +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut args = std::env::args().skip(1); + let (Some(in_path), Some(out_path)) = (args.next(), args.next()) else { + eprintln!("usage: pen2op "); + return ExitCode::from(2); + }; + + let src = match std::fs::read_to_string(&in_path) { + Ok(s) => s, + Err(e) => { + eprintln!("[pen2op] read {in_path} failed: {e}"); + return ExitCode::FAILURE; + } + }; + + // Load THROUGH the canonical loader so the legacy-tolerance normalize runs. + let loaded = match op_pen_loader::load_canonical(&src) { + Ok(loaded) => loaded, + Err(e) => { + eprintln!("[pen2op] load_canonical({in_path}) failed: {e:?}"); + return ExitCode::FAILURE; + } + }; + + if !loaded.warnings.is_empty() { + eprintln!( + "[pen2op] {in_path}: {} load warning(s): {:?}", + loaded.warnings.len(), + loaded.warnings + ); + } + + // Same serialize path as op-smoke's `state.doc` dump. + let json = match serde_json::to_string_pretty(&loaded.value) { + Ok(j) => j, + Err(e) => { + eprintln!("[pen2op] serialize {in_path} failed: {e}"); + return ExitCode::FAILURE; + } + }; + + if let Err(e) = std::fs::write(&out_path, &json) { + eprintln!("[pen2op] write {out_path} failed: {e}"); + return ExitCode::FAILURE; + } + + // Node count: walk the serialized JSON counting objects bearing a `type` + // key (matches how the loader's normalize pass identifies PenNodes). + let node_count = serde_json::from_str::(&json) + .map(|v| count_typed_objects(&v)) + .unwrap_or(0); + + println!( + "OK {in_path} -> {out_path} nodes={node_count} version={}", + loaded.value.version + ); + ExitCode::SUCCESS +} + +/// Count objects carrying a `"type"` key anywhere in the tree (iterative walk, +/// no recursion, so deep trees don't blow the stack). +fn count_typed_objects(root: &serde_json::Value) -> usize { + use serde_json::Value; + let mut count = 0usize; + let mut stack = vec![root]; + while let Some(v) = stack.pop() { + match v { + Value::Object(map) => { + if map.contains_key("type") { + count += 1; + } + for child in map.values() { + stack.push(child); + } + } + Value::Array(arr) => stack.extend(arr.iter()), + _ => {} + } + } + count +} diff --git a/crates/op-pen-loader/src/layout_scene.rs b/crates/op-pen-loader/src/layout_scene.rs index c1449a54b..434c2724a 100644 --- a/crates/op-pen-loader/src/layout_scene.rs +++ b/crates/op-pen-loader/src/layout_scene.rs @@ -24,15 +24,16 @@ use jian_scene::layout_scene::NodeKind; use jian_scene::layout_scene::{ stable_image_source_id, DropShadow, Effect, LayoutScene, SceneFillType, SceneGradient, - SceneGradientStop, SceneImageFit, SceneNode, ScenePage, SceneStroke, SceneTextAlign, - SceneTextRun, SceneTextVerticalAlign, SceneWidget, SceneWidgetOption, + SceneGradientStop, SceneImageFit, SceneNode, ScenePage, SceneShader, SceneShaderUniform, + SceneStroke, SceneTextAlign, SceneTextRun, SceneTextVerticalAlign, SceneWidget, + SceneWidgetOption, }; use op_editor_core::render_backend::Color; use op_editor_core::scene_vars::VariableTable; use crate::editor_state_var_table; use crate::payload::{ - DocPayload, GradientPayload, GradientStopPayload, NodePayload, StrokePayload, + DocPayload, GradientPayload, GradientStopPayload, NodePayload, ShaderPayload, StrokePayload, }; /// Build a paint-only [`LayoutScene`] from an editor state. @@ -216,6 +217,10 @@ fn node_payload_to_scene( .gradient .as_ref() .map(|g| scale_gradient_opacity(payload_gradient_to_scene(g), cum_opacity)), + shader: node + .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); @@ -377,6 +382,17 @@ fn scale_gradient_opacity(g: SceneGradient, k: f32) -> SceneGradient { opacity: (opacity * k).clamp(0.0, 1.0), stops, }, + SceneGradient::Mesh { + rows, + cols, + colors, + opacity, + } => SceneGradient::Mesh { + rows, + cols, + colors, + opacity: (opacity * k).clamp(0.0, 1.0), + }, } } @@ -512,6 +528,17 @@ fn payload_gradient_to_scene(g: &GradientPayload) -> SceneGradient { opacity: *opacity, stops: stops.iter().map(stop_to_scene).collect(), }, + GradientPayload::Mesh { + rows, + cols, + colors, + opacity, + } => SceneGradient::Mesh { + rows: *rows, + cols: *cols, + colors: colors.iter().map(|c| array_to_color(*c)).collect(), + opacity: *opacity, + }, } } @@ -522,12 +549,35 @@ fn stop_to_scene(s: &GradientStopPayload) -> SceneGradientStop { } } +/// Convert a [`ShaderPayload`] into the paint-only [`SceneShader`]. +/// The SkSL source + pre-resolved uniforms ride through unchanged; +/// node opacity (`k`) folds into the shader's own opacity multiplier. +/// The `fallback` `[r,g,b,a]` becomes the visible solid colour for +/// backends that can't run the program. +fn payload_shader_to_scene(s: &ShaderPayload, k: f32) -> SceneShader { + SceneShader { + sksl: s.sksl.clone(), + uniforms: s + .uniforms + .iter() + .map(|u| SceneShaderUniform { + name: u.name.clone(), + values: u.values.clone(), + }) + .collect(), + opacity: (s.opacity * k).clamp(0.0, 1.0), + fallback: array_to_color(s.fallback), + } +} + /// `NodePayload.fill_type` string → scene `SceneFillType`. Mirrors /// `payload::str_to_fill_type` followed by `fill_type_to_scene`. fn str_to_scene_fill_type(s: &str) -> SceneFillType { match s { "linear" => SceneFillType::LinearGradient, "radial" => SceneFillType::RadialGradient, + "mesh" => SceneFillType::MeshGradient, + "shader" => SceneFillType::Shader, "image" => SceneFillType::Image, _ => SceneFillType::Solid, } diff --git a/crates/op-pen-loader/src/layout_scene_tests.rs b/crates/op-pen-loader/src/layout_scene_tests.rs index 3bce0a137..66d5a940e 100644 --- a/crates/op-pen-loader/src/layout_scene_tests.rs +++ b/crates/op-pen-loader/src/layout_scene_tests.rs @@ -415,6 +415,103 @@ fn linear_gradient_payload_threads_into_scene_node() { } } +#[test] +fn mesh_gradient_payload_threads_into_scene_node_with_first_vertex_fallback() { + // A mesh_gradient first-fill must come out of the scene builder as + // `SceneNode.gradient = Some(Mesh { .. })` (so the native + // draw_vertices path renders the Gouraud blend) AND populate + // `SceneNode.fill` with the first-vertex colour as the documented + // solid fallback (backends without per-vertex support paint flat). + let src = r##"{ + "version":"1.0.0","pages":[{"id":"p","name":"P","children":[{ + "type":"rectangle","id":"r","width":100,"height":100, + "fill":[{"type":"mesh_gradient","rows":2,"cols":2, + "stops":[{"row":0,"col":0,"color":"#FF0000"}, + {"row":0,"col":1,"color":"#00FF00"}, + {"row":1,"col":0,"color":"#0000FF"}, + {"row":1,"col":1,"color":"#FFFF00"}]}] + }]}],"children":[] + }"##; + let scene = editor_state_to_layout_scene(&state_from(src)); + let n = &scene.pages[0].children[0]; + let g = n.gradient.as_ref().expect("scene gradient must populate"); + match g { + jian_scene::layout_scene::SceneGradient::Mesh { + rows, cols, colors, .. + } => { + assert_eq!(*rows, 2); + assert_eq!(*cols, 2); + assert_eq!(colors.len(), 4); + // Row-major: (0,0) red, (0,1) green, (1,0) blue, (1,1) yellow. + assert!(colors[0].r > 0.99 && colors[0].g < 0.01 && colors[0].b < 0.01); + assert!(colors[1].g > 0.99 && colors[1].r < 0.01); + assert!(colors[2].b > 0.99 && colors[2].r < 0.01); + } + other => panic!("expected mesh, got {other:?}"), + } + // First-vertex solid fallback baked into node.fill. + let fill = n.fill.expect("mesh fill must carry first-vertex fallback"); + assert!(fill.r > 0.99 && fill.g < 0.01 && fill.b < 0.01); +} + +#[test] +fn shader_payload_threads_into_scene_node_with_uniforms_and_fallback() { + // A `shader` first-fill must come out of the scene builder as + // `SceneNode.shader = Some(..)` (so the native RuntimeEffect path + // runs the program) AND bake the first colour-uniform into + // `SceneNode.fill` as the documented solid fallback. + let src = r##"{ + "version":"1.0.0","pages":[{"id":"p","name":"P","children":[{ + "type":"rectangle","id":"r","width":240,"height":240, + "fill":[{"type":"shader", + "sksl":"half4 main(float2 p){ return half4(p.x/240.0, p.y/240.0, 0.5, 1.0); }", + "uniforms":{"glow":0.5,"tint":"#FF0000"}}] + }]}],"children":[] + }"##; + let scene = editor_state_to_layout_scene(&state_from(src)); + let n = &scene.pages[0].children[0]; + let s = n.shader.as_ref().expect("scene shader must populate"); + assert!(s.sksl.contains("half4 main(float2 p)")); + // `glow` (float) + `tint` (color → premultiplied vec4) bind through. + let glow = s.uniforms.iter().find(|u| u.name == "glow").expect("glow"); + assert_eq!(glow.values, vec![0.5]); + let tint = s.uniforms.iter().find(|u| u.name == "tint").expect("tint"); + assert_eq!(tint.values.len(), 4); + // Premultiplied opaque red → (1,0,0,1). + assert!(tint.values[0] > 0.99 && tint.values[1] < 0.01 && tint.values[3] > 0.99); + // fill_type is "shader". + assert_eq!(n.fill_type, jian_scene::layout_scene::SceneFillType::Shader); + // First colour-uniform solid fallback baked into node.fill (red). + let fill = n + .fill + .expect("shader fill must carry colour-uniform fallback"); + assert!(fill.r > 0.99 && fill.g < 0.01 && fill.b < 0.01); + // Fallback colour on the scene shader itself is red too. + assert!(s.fallback.r > 0.99 && s.fallback.g < 0.01); +} + +#[test] +fn shader_payload_without_color_uniform_falls_back_mid_gray() { + // A shader with no colour uniform must still bake a visible fallback + // (mid-gray) into both node.fill and the scene shader, so a host + // that can't compile the program shows something. + let src = r##"{ + "version":"1.0.0","pages":[{"id":"p","name":"P","children":[{ + "type":"rectangle","id":"r","width":100,"height":100, + "fill":[{"type":"shader", + "sksl":"half4 main(float2 p){ return half4(0.0,0.0,0.0,1.0); }"}] + }]}],"children":[] + }"##; + let scene = editor_state_to_layout_scene(&state_from(src)); + let n = &scene.pages[0].children[0]; + let s = n.shader.as_ref().expect("scene shader must populate"); + assert!(s.uniforms.is_empty()); + // Mid-gray fallback (0.5, 0.5, 0.5). + assert!((s.fallback.r - 0.5).abs() < 0.02 && (s.fallback.g - 0.5).abs() < 0.02); + let fill = n.fill.expect("shader fill must carry mid-gray fallback"); + assert!((fill.r - 0.5).abs() < 0.02); +} + #[test] fn image_fill_mode_threads_into_scene_node() { let src = r##"{ diff --git a/crates/op-pen-loader/src/lib.rs b/crates/op-pen-loader/src/lib.rs index cfbe86e6f..b11cb9cc0 100644 --- a/crates/op-pen-loader/src/lib.rs +++ b/crates/op-pen-loader/src/lib.rs @@ -25,6 +25,7 @@ mod effects; mod layout_repair; mod layout_scene; mod legacy_payload_repair; +mod library; // Only the real-shaper (`skia-measure`) build benefits from caching; the // estimate backend is already cheap, so the module is gated to avoid dead code // under the CanvasKit (no-skia-measure) web build. @@ -61,6 +62,7 @@ pub use effects::{ effects_from_payload, effects_from_payload_ref, effects_to_payload, shadows_from_canonical, ShadowPayload, }; +pub use library::{merge_library_into_state, merge_library_src_into_state, LibraryMergeReport}; pub use payload::{load_canonical, DocPayload, NodePayload, PagePayload, StrokePayload}; pub use variables::{var_table_from_payload, var_table_to_payload, VarTablePayload}; diff --git a/crates/op-pen-loader/src/library.rs b/crates/op-pen-loader/src/library.rs new file mode 100644 index 000000000..17637a197 --- /dev/null +++ b/crates/op-pen-loader/src/library.rs @@ -0,0 +1,236 @@ +//! Component-library loading + merge. +//! +//! A *component library* is an ordinary `.op` / `.lib.op` document whose +//! top-level (or per-page) frames carry `reusable: true`. Loading one into +//! a working document makes those masters addressable by `ref` nodes during +//! AI generation — the "design-kit composition" path. +//! +//! [`merge_library_into_state`] reads such a file via the canonical loader, +//! harvests its reusable masters with +//! [`op_editor_core::ComponentLibrary::from_document`], and merges them into +//! a live [`EditorState`]: +//! +//! 1. the master frames are appended to `state.doc.children` (deduped by id), +//! 2. the library's `variables` + `themes` are merged into the doc (so each +//! master's `$--token` fills resolve), and +//! 3. `state.components` is rebuilt so the runtime registry + the generator's +//! available-components manifest see the new masters immediately. +//! +//! This is intentionally additive and gated: nothing calls it on the default +//! path. The smoke runner wires it behind `OPENPENCIL_SMOKE_LIBRARY`, and the +//! desktop host can call it when a user imports a kit. + +use op_editor_core::pen_node_ext::PenNodeExt; +use op_editor_core::{ComponentLibrary, EditorState}; + +use crate::payload::load_canonical; + +/// Outcome of a successful library merge — counts for logging / tests. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LibraryMergeReport { + /// Reusable master frames appended to `doc.children` (post-dedup). + pub masters_added: usize, + /// Total reusable masters now registered in `state.components`. + pub component_count: usize, + /// Variable definitions merged in from the library (newly added only). + pub variables_added: usize, + /// Theme axes merged in from the library (newly added only). + pub themes_added: usize, +} + +/// Load the `.lib.op` at `path`, harvest its reusable masters, and merge them +/// (plus its variables + themes) into `state`. Existing document content is +/// preserved; masters/variables/themes whose ids already exist are skipped so +/// re-importing the same library is idempotent. +/// +/// Returns the merge report on success, or a human-readable error string when +/// the file can't be read or parsed. On error `state` is left unchanged. +pub fn merge_library_into_state( + state: &mut EditorState, + path: &str, +) -> Result { + let src = std::fs::read_to_string(path).map_err(|e| format!("read library {path}: {e}"))?; + merge_library_src_into_state(state, &src).map_err(|e| format!("load library {path}: {e}")) +} + +/// Source-string variant of [`merge_library_into_state`] — parses canonical +/// `.op` JSON already in memory. Shared core so tests can drive it without a +/// temp file. +pub fn merge_library_src_into_state( + state: &mut EditorState, + src: &str, +) -> Result { + let loaded = load_canonical(src).map_err(|e| e.to_string())?; + let lib_doc = loaded.value; + + // Harvest reusable masters from the library document (top-level + pages). + let library = ComponentLibrary::from_document(&lib_doc); + + let mut report = LibraryMergeReport::default(); + + // 1. Append master frames to the working doc, deduped by id. + let existing_ids: std::collections::HashSet = state + .doc + .children + .iter() + .map(|n| n.id_str().to_string()) + .collect(); + let mut added_ids = existing_ids.clone(); + for component in &library.components { + let id = component.root.id_str().to_string(); + if added_ids.contains(&id) { + continue; + } + added_ids.insert(id); + state.doc.children.push(component.root.clone()); + report.masters_added += 1; + } + + // 2. Merge the library's variables (only new names) so master `$--token` + // fills resolve against concrete values. + if let Some(lib_vars) = lib_doc.variables.as_ref() { + let dst = state.doc.variables.get_or_insert_with(Default::default); + for (name, def) in lib_vars { + if !dst.contains_key(name) { + dst.insert(name.clone(), def.clone()); + report.variables_added += 1; + } + } + } + + // 3. Merge the library's theme axes (only new axis names). + if let Some(lib_themes) = lib_doc.themes.as_ref() { + let dst = state.doc.themes.get_or_insert_with(Default::default); + for (axis, values) in lib_themes { + if !dst.contains_key(axis) { + dst.insert(axis.clone(), values.clone()); + report.themes_added += 1; + } + } + } + + // 4. Rebuild the runtime component registry off the merged document so the + // generator's available-components manifest sees the new masters now. + state.components = ComponentLibrary::from_document(&state.doc); + report.component_count = state.components.len(); + + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One reusable master frame as canonical `.op` JSON (a button-like + /// frame whose fill references a `$--primary` token so the merge's + /// variable handling matters). Built with `serde_json` so color + /// literals like `#18181B` don't trip the raw-string `r#` prefix rules. + fn reusable_frame_json(id: &str, name: &str) -> serde_json::Value { + serde_json::json!({ + "id": id, + "type": "frame", + "name": name, + "reusable": true, + "width": 120, + "height": 40, + "layout": "horizontal", + "cornerRadius": 8, + "fill": [{ "type": "solid", "color": "$--primary" }], + "children": [ + { "id": format!("{id}-label"), "type": "text", "name": "Label", + "content": name, "fontSize": 14 } + ], + }) + } + + /// A library document (JSON) with `n` reusable masters + variables + a + /// theme axis. Serialized to a canonical `.op` JSON string so it goes + /// through the exact same `load_canonical` path a real `.lib.op` file + /// would. + fn library_src(n: usize) -> String { + let children: Vec = (0..n) + .map(|i| reusable_frame_json(&format!("lib-comp-{i}"), &format!("Component {i}"))) + .collect(); + let doc = serde_json::json!({ + "version": "1.0", + "name": "Test Library", + "themes": { "mode": ["light", "dark"] }, + "variables": { + "--primary": { "type": "color", "value": "#18181B" }, + "--surface": { "type": "color", "value": "#FAFAFA" }, + }, + "children": children, + }); + serde_json::to_string(&doc).unwrap() + } + + #[test] + fn merges_masters_variables_and_themes_into_empty_state() { + let mut state = EditorState::new(); + let src = library_src(120); + let report = merge_library_src_into_state(&mut state, &src).expect("merge"); + + assert_eq!(report.masters_added, 120); + assert_eq!(report.component_count, 120); + assert!( + report.component_count > 100, + "component count must exceed 100" + ); + assert_eq!(report.variables_added, 2); + assert_eq!(report.themes_added, 1); + // The masters landed in the working doc. + assert_eq!(state.doc.children.len(), 120); + // Variables + themes are present so master `$--token` fills resolve. + assert!(state + .doc + .variables + .as_ref() + .unwrap() + .contains_key("--primary")); + assert!(state.doc.themes.as_ref().unwrap().contains_key("mode")); + // The runtime registry sees them too. + assert_eq!(state.components.len(), 120); + assert!(state.components.find_by_name("Component 7").is_some()); + } + + #[test] + fn dedup_keeps_existing_and_is_idempotent() { + let mut state = EditorState::new(); + let src = library_src(5); + let first = merge_library_src_into_state(&mut state, &src).expect("first"); + assert_eq!(first.masters_added, 5); + // Re-importing the same library adds nothing new. + let second = merge_library_src_into_state(&mut state, &src).expect("second"); + assert_eq!(second.masters_added, 0); + assert_eq!(second.variables_added, 0); + assert_eq!(second.themes_added, 0); + // Component count is stable. + assert_eq!(second.component_count, 5); + assert_eq!(state.doc.children.len(), 5); + } + + #[test] + fn preserves_existing_document_content() { + // A working document with one ordinary (non-reusable) user frame. + let user_doc_src = r#"{"version":"1.0","children":[ + {"id":"user-frame","type":"frame","name":"User Frame","width":200,"height":100} + ]}"#; + let loaded = load_canonical(user_doc_src).expect("user doc"); + let mut state = EditorState::from_document(loaded.value); + let src = library_src(3); + let report = merge_library_src_into_state(&mut state, &src).expect("merge"); + assert_eq!(report.masters_added, 3); + // User content survived + masters appended after it. + assert_eq!(state.doc.children.len(), 4); + assert_eq!(state.doc.children[0].id_str(), "user-frame"); + } + + #[test] + fn bad_source_leaves_state_unchanged() { + let mut state = EditorState::new(); + let err = merge_library_src_into_state(&mut state, "not json").unwrap_err(); + assert!(!err.is_empty()); + assert!(state.doc.children.is_empty()); + assert!(state.components.is_empty()); + } +} diff --git a/crates/op-pen-loader/src/payload.rs b/crates/op-pen-loader/src/payload.rs index 0343269cf..9acd96b31 100644 --- a/crates/op-pen-loader/src/payload.rs +++ b/crates/op-pen-loader/src/payload.rs @@ -103,6 +103,12 @@ pub struct NodePayload { /// a fallback for paint paths that don't grok gradients. #[serde(default, skip_serializing_if = "Option::is_none")] pub gradient: Option, + /// Resolved native SkSL shader body for the first fill when it is a + /// `Shader`. `None` for every other fill type. `fill` still carries + /// the shader's fallback colour for paint paths that can't run the + /// program. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shader: Option, #[serde(default)] pub points: Vec<[f32; 2]>, /// Path bezier anchors (absolute doc coords, handles resolved). @@ -333,6 +339,42 @@ pub enum GradientPayload { opacity: f32, stops: Vec, }, + /// Uniform-grid mesh gradient (v1). `colors` is a row-major + /// `rows`×`cols` lattice of pre-resolved RGBA values (length == + /// `rows * cols`); vertex `(r, c)` lives at `colors[r * cols + c]`. + /// Opacity is carried separately and folded by the painter (parity + /// with how the Linear / Radial variants thread `opacity`). + Mesh { + rows: u32, + cols: u32, + colors: Vec<[f32; 4]>, + opacity: f32, + }, +} + +/// One resolved SkSL shader uniform — name plus a concrete float vector +/// (length 1 = float, 2/3/4 = vec*). A `color` uniform is pre-expanded +/// into a 4-float premultiplied-RGBA `vec4` here so the scene builder + +/// painter never re-walk the canonical schema. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShaderUniformPayload { + pub name: String, + pub values: Vec, +} + +/// Layout-resolved native SkSL shader body for `NodePayload.shader`. +/// `sksl` is the RAW (untrusted) source; uniforms are pre-resolved. +/// `fallback` is the `[r,g,b,a]` solid colour painted when a host can't +/// compile the program (first `color` uniform, else mid-gray) — kept +/// alongside `NodePayload.fill` so the degradation path always has a +/// visible colour. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShaderPayload { + pub sksl: String, + #[serde(default)] + pub uniforms: Vec, + pub opacity: f32, + pub fallback: [f32; 4], } /// One path bezier anchor in absolute doc coords. `handle_in` / @@ -407,7 +449,12 @@ pub fn load_canonical( fn normalize_legacy_doc(src: &str) -> Option { let mut value: serde_json::Value = serde_json::from_str(src).ok()?; let mut changed = false; - normalize_node_value(&mut value, &mut changed); + // Pencil design-kit `.pen` files reference `$--radius-*` number variables + // from `cornerRadius`; the canonical `CornerRadius` enum can only hold a + // number / `[f64; 4]`, so collect the document's number-variable table up + // front to resolve those refs to concrete radii during the walk. + let radius_vars = collect_number_variables(&value); + normalize_node_value(&mut value, &radius_vars, &mut changed); if changed { serde_json::to_string(&value).ok() } else { @@ -415,36 +462,131 @@ fn normalize_legacy_doc(src: &str) -> Option { } } +/// Harvest `variables. = { type: "number", value: N | [{value, theme}] }` +/// into a `$` → first-concrete-number map. Used to resolve +/// `cornerRadius: "$--radius-pill"` legacy refs (Pencil design kits) into the +/// number the canonical `CornerRadius` enum can actually hold. Theme-keyed +/// arrays collapse to their first entry's value — corner radius is rarely +/// theme-varied, and a constant fallback beats refusing the whole file. +fn collect_number_variables(root: &serde_json::Value) -> std::collections::HashMap { + use serde_json::Value; + let mut out = std::collections::HashMap::new(); + let Some(vars) = root.get("variables").and_then(Value::as_object) else { + return out; + }; + for (name, def) in vars { + if def.get("type").and_then(Value::as_str) != Some("number") { + continue; + } + let resolved = match def.get("value") { + Some(Value::Number(n)) => n.as_f64(), + Some(Value::Array(entries)) => entries + .first() + .and_then(|e| e.get("value")) + .and_then(Value::as_f64), + _ => None, + }; + if let Some(v) = resolved { + out.insert(format!("${name}"), v); + } + } + out +} + /// Iterative (explicit stack, not recursion — deep trees must not blow the -/// stack) walk. Objects carrying a `type` key are PenNodes: jian requires -/// their `fill` to be `Vec` and (for images) a `src`. Type-less -/// objects (e.g. `StyledTextSegment`, whose `fill` is a `String`) are left -/// untouched. -fn normalize_node_value(root: &mut serde_json::Value, changed: &mut bool) { +/// stack) walk that repairs known legacy `.pen` / `.op` shapes the strict +/// canonical schema rejects but the TS runtime tolerated: +/// +/// - PenNode `fill` written as a bare color/`$ref` string → wrap into the +/// `Vec` jian expects. +/// - Image node missing the required `src`. +/// - Pencil legacy `type: "icon"` → canonical `icon_font`. +/// - A `stroke` written as a bare color string → wrap into a `PenStroke`. +/// - A stroke object's `fill` string (no `type` key on the stroke object) → +/// wrap into `Vec` (same shape as node fill). +/// - `cornerRadius` written as a `$ref` string (or array of them) → resolved +/// to the concrete number(s) the `CornerRadius` enum can hold. +/// +/// Type-less objects whose `fill` is legitimately a `String` +/// (`StyledTextSegment`) are left untouched — the wrap is gated on the object +/// being a PenNode (`type` key) or a stroke (`thickness` key). +fn normalize_node_value( + root: &mut serde_json::Value, + number_vars: &std::collections::HashMap, + changed: &mut bool, +) { use serde_json::Value; let mut stack = vec![root]; while let Some(node) = stack.pop() { match node { Value::Object(map) => { - if map.contains_key("type") { - if map.get("type").and_then(Value::as_str) == Some("image") - && !map.contains_key("src") - { + let is_pen_node = map.contains_key("type"); + if is_pen_node { + // Copy the kind decisions to owned bools so the mutating + // inserts below don't conflict with the borrow on `map`. + let kind = map.get("type").and_then(Value::as_str); + let is_image = kind == Some("image"); + let is_icon = kind == Some("icon"); + let is_prompt = kind == Some("prompt"); + if is_image && !map.contains_key("src") { map.insert("src".to_string(), Value::String(String::new())); *changed = true; } - // A PenNode `fill` written as a bare color string → wrap - // into the canonical solid-fill array jian expects. - if let Some(color) = map.get("fill").and_then(|f| match f { + // Pencil's `prompt` node is an AI-annotation card with no + // canonical equivalent → degrade to a `text` node. Its + // `content` field already matches `TextNode.content`; just + // drop the editor-only `model` field. + if is_prompt { + map.insert("type".to_string(), Value::String("text".to_string())); + map.remove("model"); + *changed = true; + } + // Pencil's legacy `icon` node is jian's `icon_font`: the + // glyph lives under `icon` (→ `iconFontName`) and the font + // family under `library` (→ `iconFontFamily`). + if is_icon { + map.insert("type".to_string(), Value::String("icon_font".to_string())); + if let Some(glyph) = map.remove("icon") { + map.insert("iconFontName".to_string(), glyph); + } + if let Some(family) = map.remove("library") { + map.insert("iconFontFamily".to_string(), family); + } + *changed = true; + } + // A PenNode `fill` written as a bare string / single object / + // legacy `type:"color"` array → canonical `Vec`. + normalize_fill(map, changed); + // `cornerRadius` as a `$ref` string / array of them → + // resolve to the number(s) the `CornerRadius` enum holds. + normalize_corner_radius(map, number_vars, changed); + // A `stroke` written as a bare color string → wrap into a + // minimal `PenStroke { thickness: 1, fill: [...] }`. + if let Some(color) = map.get("stroke").and_then(|s| match s { Value::String(s) => Some(s.clone()), _ => None, }) { map.insert( - "fill".to_string(), - serde_json::json!([{ "type": "solid", "color": color }]), + "stroke".to_string(), + serde_json::json!({ + "thickness": 1, + "fill": [{ "type": "solid", "color": color }], + }), ); *changed = true; } + } else if map.contains_key("thickness") { + // Stroke object (has `thickness`, no `type`): its `fill` + // is the same normalization target as a node's. + normalize_fill(map, changed); + } + // Spacing fields (`padding` / `gap` / `margin`) may be numeric + // arrays with an embedded `$ref` string (`[8, "$spacing/3"]`). + // The `Padding` enum's array arms are all-number, so resolve any + // ref element to its number. A bare-string padding (`"$x"`) is + // left alone — the `Expression(String)` arm accepts it. + for key in ["padding", "gap", "margin"] { + resolve_spacing_ref_array(map, key, number_vars, changed); } stack.extend(map.values_mut()); } @@ -454,6 +596,222 @@ fn normalize_node_value(root: &mut serde_json::Value, changed: &mut bool) { } } +/// Normalize a `fill` into the canonical `Vec` jian expects. Handles +/// the legacy Pencil shapes the strict schema rejects: +/// - bare color/`$ref` string (`"#1A1D2E"` / `"$--sidebar-border"`) +/// - a single fill *object* (`{ "type": "color", "color": "...", "enabled": false }`) +/// rather than a one-element array +/// - the `type: "color"` discriminant (jian's solid arm is `"solid"`) +/// - an `enabled: false` flag → the fill is dropped (disabled in the source) +/// +/// No-op when `fill` is absent or already a clean canonical array. +fn normalize_fill(map: &mut serde_json::Map, changed: &mut bool) { + use serde_json::Value; + let Some(fill) = map.get("fill") else { + return; + }; + match fill { + Value::String(s) => { + let color = s.clone(); + map.insert( + "fill".to_string(), + serde_json::json!([{ "type": "solid", "color": color }]), + ); + *changed = true; + } + Value::Object(_) => { + // A single fill written as an object → array of (maybe) one, + // after legacy-discriminant + enabled normalization. + let mut one = fill.clone(); + let mut dummy = false; + let arr: Vec = match normalize_fill_entry(&mut one, &mut dummy) { + Some(v) => vec![v], + None => vec![], + }; + map.insert("fill".to_string(), Value::Array(arr)); + *changed = true; + } + Value::Array(items) => { + // Already an array — but its elements may still carry the legacy + // `type: "color"` discriminant or an `enabled: false` flag. + let mut local_changed = false; + let mut out = Vec::with_capacity(items.len()); + for item in items.clone() { + let mut entry = item; + if let Some(v) = normalize_fill_entry(&mut entry, &mut local_changed) { + out.push(v); + } + } + if local_changed { + map.insert("fill".to_string(), Value::Array(out)); + *changed = true; + } + } + _ => {} + } +} + +/// Normalize one PenFill value. Returns `None` when the fill is explicitly +/// `enabled: false` (a disabled fill is dropped from the array). Rewrites the +/// legacy `type: "color"` discriminant to `"solid"` and strips the non-schema +/// `enabled` key. Sets `changed` when it touches anything. +fn normalize_fill_entry( + entry: &mut serde_json::Value, + changed: &mut bool, +) -> Option { + use serde_json::Value; + let Value::Object(obj) = entry else { + return Some(entry.clone()); + }; + if obj.get("enabled") == Some(&Value::Bool(false)) { + *changed = true; + return None; + } + if obj.remove("enabled").is_some() { + *changed = true; + } + match obj.get("type").and_then(Value::as_str) { + Some("color") => { + obj.insert("type".to_string(), Value::String("solid".to_string())); + *changed = true; + } + Some("gradient") => { + normalize_gradient_fill(obj, changed); + } + _ => {} + } + Some(Value::Object(obj.clone())) +} + +/// Rewrite Pencil's generic `type: "gradient"` fill into the canonical +/// `linear_gradient` / `radial_gradient` PenFill. Source shape: +/// `{ type: "gradient", gradientType: "linear"|"radial", rotation, colors: [{ color, position }], size }`. +/// Canonical shape: `{ type: "linear_gradient", angle, stops: [{ offset, color }] }`. +fn normalize_gradient_fill( + obj: &mut serde_json::Map, + changed: &mut bool, +) { + use serde_json::Value; + let radial = obj.get("gradientType").and_then(Value::as_str) == Some("radial"); + obj.insert( + "type".to_string(), + Value::String( + if radial { + "radial_gradient" + } else { + "linear_gradient" + } + .to_string(), + ), + ); + obj.remove("gradientType"); + // `rotation` (degrees) → `angle`; only for the linear arm (radial ignores it). + if let Some(rot) = obj.remove("rotation") { + if !radial { + obj.insert("angle".to_string(), rot); + } + } + obj.remove("size"); + // `colors: [{ color, position }]` → `stops: [{ offset, color }]`. + if let Some(Value::Array(colors)) = obj.remove("colors") { + let stops: Vec = colors + .into_iter() + .filter_map(|c| { + let o = c.as_object()?; + let color = o.get("color").and_then(Value::as_str)?.to_string(); + let offset = o.get("position").and_then(Value::as_f64).unwrap_or(0.0); + Some(serde_json::json!({ "offset": offset, "color": color })) + }) + .collect(); + obj.insert("stops".to_string(), Value::Array(stops)); + } else if !obj.contains_key("stops") { + // A gradient with no stops is illegal; seed an empty array so the + // canonical loader gets a valid (if degenerate) gradient body. + obj.insert("stops".to_string(), Value::Array(vec![])); + } + *changed = true; +} + +/// Resolve `$ref` strings embedded in a numeric spacing array +/// (`padding`/`gap`/`margin`: `[8, "$spacing/3"]`) to their concrete numbers. +/// No-op unless the value is an array containing at least one `$ref` string — +/// a bare-string spacing is left for the schema's `Expression` arm. +fn resolve_spacing_ref_array( + map: &mut serde_json::Map, + key: &str, + number_vars: &std::collections::HashMap, + changed: &mut bool, +) { + use serde_json::Value; + let Some(Value::Array(items)) = map.get(key) else { + return; + }; + if !items.iter().any(Value::is_string) { + return; + } + let resolved: Vec = items + .iter() + .map(|v| match v { + Value::String(s) => { + serde_json::json!(number_vars.get(s).copied().unwrap_or(0.0)) + } + other => other.clone(), + }) + .collect(); + map.insert(key.to_string(), Value::Array(resolved)); + *changed = true; +} + +/// Resolve a `cornerRadius` that is a `$ref` string, or an array of +/// `$ref`/number entries, into the number / `[f64; 4]` the canonical +/// `CornerRadius` enum accepts. A ref with no matching number variable falls +/// back to `0.0` so the file still loads (an unresolved radius is cosmetic). +fn normalize_corner_radius( + map: &mut serde_json::Map, + number_vars: &std::collections::HashMap, + changed: &mut bool, +) { + use serde_json::Value; + let resolve_scalar = |v: &Value| -> Option { + match v { + Value::Number(n) => n.as_f64(), + Value::String(s) => Some(number_vars.get(s).copied().unwrap_or(0.0)), + _ => None, + } + }; + match map.get("cornerRadius") { + Some(Value::String(s)) => { + let n = number_vars.get(s).copied().unwrap_or(0.0); + map.insert("cornerRadius".to_string(), serde_json::json!(n)); + *changed = true; + } + Some(Value::Array(entries)) => { + // Only rewrite if at least one entry is a `$ref` string (a plain + // numeric `[f64; 4]` is already valid; leave it alone). + if entries.iter().any(|e| e.is_string()) { + let nums: Vec = entries.iter().filter_map(&resolve_scalar).collect(); + // The enum's array arm is exactly `[f64; 4]`. Pad/truncate so a + // 1- or 2-value Pencil shorthand still lands in a legal shape. + let four = match nums.len() { + 0 => [0.0; 4], + 1 => [nums[0]; 4], + n if n >= 4 => [nums[0], nums[1], nums[2], nums[3]], + _ => { + let mut a = [0.0; 4]; + for (i, v) in nums.iter().enumerate() { + a[i] = *v; + } + a + } + }; + map.insert("cornerRadius".to_string(), serde_json::json!(four)); + *changed = true; + } + } + _ => {} + } +} + fn load_str_or_deep( src: &str, ) -> Result< diff --git a/crates/op-pen-loader/src/style_payload.rs b/crates/op-pen-loader/src/style_payload.rs index 731195cd3..5f7fbd302 100644 --- a/crates/op-pen-loader/src/style_payload.rs +++ b/crates/op-pen-loader/src/style_payload.rs @@ -3,10 +3,13 @@ use jian_ops_schema::node::base::PenNodeBase; use jian_ops_schema::node::container::CornerRadius; use jian_ops_schema::node::{ImageFitMode, ImageNode}; -use jian_ops_schema::style::{ImageFillMode, PenFill, PenStroke, StrokeThickness}; +use jian_ops_schema::style::{ + ImageFillMode, PenFill, PenStroke, ShaderUniformValue, StrokeThickness, +}; use crate::payload::{ - GradientPayload, GradientStopPayload, ImageAdjustmentPayload, NodePayload, StrokePayload, + GradientPayload, GradientStopPayload, ImageAdjustmentPayload, NodePayload, ShaderPayload, + ShaderUniformPayload, StrokePayload, }; pub(crate) fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload { @@ -38,6 +41,7 @@ pub(crate) fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload { collapsed: false, fill_type: "solid".into(), gradient: None, + shader: None, points: Vec::new(), path_anchors: Vec::new(), path_closed: false, @@ -88,6 +92,7 @@ pub(crate) fn assign_first_fill(p: &mut NodePayload, fills: Option<&[PenFill]>) p.fill = first_solid_color(fills); p.fill_type = first_fill_type(fills); p.gradient = first_gradient(fills); + p.shader = first_shader(fills); if let Some((url, fit, adjustments)) = first_image_fill(fills) { p.image_src = Some(url); p.image_fit = Some(fit); @@ -211,10 +216,97 @@ fn first_gradient(fills: Option<&[PenFill]>) -> Option { stops, }) } + PenFill::MeshGradient(body) => { + let colors = mesh_colors(body)?; + Some(GradientPayload::Mesh { + rows: body.rows.max(2), + cols: body.cols.max(2), + colors, + opacity: body.opacity.unwrap_or(1.0).clamp(0.0, 1.0), + }) + } _ => None, } } +/// Resolve the first fill into a [`ShaderPayload`] when it is a +/// `Shader`. Uniforms are pre-resolved (a `color` hex → premultiplied +/// `vec4`); the fallback colour is the first `color` uniform, else +/// mid-gray, so a host that can't compile the program still paints a +/// visible block. SkSL source stays untrusted — not validated here. +fn first_shader(fills: Option<&[PenFill]>) -> Option { + let PenFill::Shader(body) = fills?.first()? else { + return None; + }; + if body.sksl.trim().is_empty() { + return None; + } + let mut uniforms: Vec = Vec::new(); + let mut fallback: Option<[f32; 4]> = None; + if let Some(map) = &body.uniforms { + for (name, val) in map { + match val { + ShaderUniformValue::Float(f) => uniforms.push(ShaderUniformPayload { + name: name.clone(), + values: vec![*f], + }), + ShaderUniformValue::Vec(v) => { + if !v.is_empty() { + uniforms.push(ShaderUniformPayload { + name: name.clone(), + values: v.clone(), + }); + } + } + ShaderUniformValue::Color(hex) => { + if let Some(rgba) = parse_hex(hex) { + // Premultiply for the vec4 binding (matches the + // jian-core scene walker's color-uniform rule). + let a = rgba[3]; + uniforms.push(ShaderUniformPayload { + name: name.clone(), + values: vec![rgba[0] * a, rgba[1] * a, rgba[2] * a, a], + }); + if fallback.is_none() { + fallback = Some(rgba); + } + } + } + } + } + } + Some(ShaderPayload { + sksl: body.sksl.clone(), + uniforms, + opacity: body.opacity.unwrap_or(1.0).clamp(0.0, 1.0), + // Mid-gray when no colour uniform exists. + fallback: fallback.unwrap_or([0.5, 0.5, 0.5, 1.0]), + }) +} + +/// Resolve a mesh body's `stops[]` into a row-major `rows`×`cols` +/// colour grid (length == `rows * cols`). Vertices missing from the +/// sparse `stops[]` default to transparent black so the grid is always +/// fully populated for the triangulator. Returns `None` for a +/// degenerate (< 2×2) grid so the caller falls back to solid. +fn mesh_colors(body: &jian_ops_schema::style::MeshGradientBody) -> Option> { + let rows = body.rows.max(2); + let cols = body.cols.max(2); + if body.rows < 2 || body.cols < 2 { + return None; + } + let mut colors = vec![[0.0, 0.0, 0.0, 0.0]; (rows * cols) as usize]; + for s in &body.stops { + if s.row >= rows || s.col >= cols { + continue; + } + if let Some(rgba) = parse_hex(&s.color) { + colors[(s.row * cols + s.col) as usize] = rgba; + } + } + Some(colors) +} + fn gradient_stops( stops: &[jian_ops_schema::style::GradientStop], ) -> Option> { @@ -255,6 +347,29 @@ fn first_solid_color(fills: Option<&[PenFill]>) -> Option<[f32; 4]> { } } } + PenFill::MeshGradient(body) => { + // First-vertex colour is the documented solid fallback + // baked into `node.fill` (backends without per-vertex + // support paint this flat). + if let Some(stop) = body.stops.first() { + if let Some(rgba) = parse_hex(&stop.color) { + return Some(apply_alpha(rgba, body.opacity)); + } + } + } + PenFill::Shader(body) => { + // Fallback solid baked into `node.fill`: the first colour + // uniform if any, else mid-gray. Backends that can't + // compile the program paint this flat. + let from_uniform = body.uniforms.as_ref().and_then(|m| { + m.values().find_map(|v| match v { + ShaderUniformValue::Color(hex) => parse_hex(hex), + _ => None, + }) + }); + let rgba = from_uniform.unwrap_or([0.5, 0.5, 0.5, 1.0]); + return Some(apply_alpha(rgba, body.opacity)); + } PenFill::Image(_) => { return Some([0.85, 0.86, 0.88, 1.0]); } @@ -270,6 +385,8 @@ fn first_fill_type(fills: Option<&[PenFill]>) -> String { match fills.first() { Some(PenFill::LinearGradient(_)) => "linear".into(), Some(PenFill::RadialGradient(_)) => "radial".into(), + Some(PenFill::MeshGradient(_)) => "mesh".into(), + Some(PenFill::Shader(_)) => "shader".into(), Some(PenFill::Image(_)) => "image".into(), _ => "solid".into(), } diff --git a/vendor/jian b/vendor/jian index 8ed9a89fa..ff39a4163 160000 --- a/vendor/jian +++ b/vendor/jian @@ -1 +1 @@ -Subproject commit 8ed9a89fa46b13bb26524e3176b708b2ed6b2970 +Subproject commit ff39a4163edee286fa7bd8c6afc969bcca2cbaec