feat(loader): mesh/shader fills + .pen→.op legacy normalize hardening + pen2op bin + design-library merge

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.
This commit is contained in:
Fini 2026-07-02 21:21:09 +08:00
parent 89e5699e69
commit bf4a741d8e
8 changed files with 976 additions and 21 deletions

View file

@ -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 <in.pen> <out.op>`
//!
//! 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 <in.pen> <out.op>");
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::<serde_json::Value>(&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
}

View file

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

View file

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

View file

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

View file

@ -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<LibraryMergeReport, String> {
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<LibraryMergeReport, String> {
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<String> = 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<serde_json::Value> = (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());
}
}

View file

@ -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<GradientPayload>,
/// 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<ShaderPayload>,
#[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<GradientStopPayload>,
},
/// 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<f32>,
}
/// 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<ShaderUniformPayload>,
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<String> {
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<String> {
}
}
/// Harvest `variables.<name> = { type: "number", value: N | [{value, theme}] }`
/// into a `$<name>` → 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<String, f64> {
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<PenFill>` 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<PenFill>` 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<PenFill>` (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<String, f64>,
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<PenFill>`.
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<PenFill>` 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<String, serde_json::Value>, 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<Value> = 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<serde_json::Value> {
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<String, serde_json::Value>,
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<Value> = 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<String, serde_json::Value>,
key: &str,
number_vars: &std::collections::HashMap<String, f64>,
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<Value> = 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<String, serde_json::Value>,
number_vars: &std::collections::HashMap<String, f64>,
changed: &mut bool,
) {
use serde_json::Value;
let resolve_scalar = |v: &Value| -> Option<f64> {
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<f64> = 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<

View file

@ -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<GradientPayload> {
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<ShaderPayload> {
let PenFill::Shader(body) = fills?.first()? else {
return None;
};
if body.sksl.trim().is_empty() {
return None;
}
let mut uniforms: Vec<ShaderUniformPayload> = 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<Vec<[f32; 4]>> {
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<Vec<GradientStopPayload>> {
@ -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(),
}

2
vendor/jian vendored

@ -1 +1 @@
Subproject commit 8ed9a89fa46b13bb26524e3176b708b2ed6b2970
Subproject commit ff39a4163edee286fa7bd8c6afc969bcca2cbaec