//! Diagnostic probe — classify vector-node decode failures in a real #![allow(dead_code)] //! `.fig` (raw fig-kiwi or zip). Mounts the crate's private decode //! modules via #[path] so no library source changes are needed. //! //! Usage: cargo run -p op-figma --example probe_vec -- #[path = "../src/container.rs"] mod container; #[path = "../src/corner_geometry.rs"] mod corner_geometry; #[path = "../src/figma_types.rs"] mod figma_types; #[path = "../src/kiwi.rs"] mod kiwi; #[path = "../src/tree.rs"] mod tree; #[path = "../src/vector_decoder.rs"] mod vector_decoder; #[path = "../src/zip_reader.rs"] mod zip_reader; use figma_types::{parse_fig_file, BlobOrString, FigMatrix}; use kiwi::FigValue; use std::collections::BTreeMap; use tree::{build_tree, guid_to_string, TreeNode}; use vector_decoder::decode_figma_vector_path; fn keys(v: &FigValue) -> Vec { match v { FigValue::Object(pairs) => pairs.iter().map(|(k, _)| k.to_string()).collect(), _ => Vec::new(), } } fn bump(map: &mut BTreeMap, key: &str) { *map.entry(key.to_string()).or_insert(0) += 1; } fn geometry_dump(node: &FigValue, blobs: &[BlobOrString], indent: &str) { println!("{indent}keys: {:?}", keys(node)); let size = node.get("size"); println!( "{indent}size=({:?},{:?}) strokeWeight={:?} align={:?} cap={:?} join={:?}", size.and_then(|value| value.get_f64("x")), size.and_then(|value| value.get_f64("y")), node.get_f64("strokeWeight"), node.get_str("strokeAlign"), node.get_str("strokeCap"), node.get_str("strokeJoin") ); if node.get_str("type") == Some("INSTANCE") { let symbol_data = node.get("symbolData"); let symbol_id = symbol_data .and_then(|data| data.get("symbolID")) .and_then(guid_to_string); let overridden_symbol_id = node.get("overriddenSymbolID").and_then(guid_to_string); let overrides = symbol_data .and_then(|data| data.get_array("symbolOverrides")) .unwrap_or(&[]); let derived = node.get_array("derivedSymbolData").unwrap_or(&[]); println!( "{indent}instance symbolID={symbol_id:?} overriddenSymbolID={overridden_symbol_id:?} overrides={} derived={}", overrides.len(), derived.len() ); if !overrides.is_empty() { println!("{indent}symbolOverrides: {overrides:?}"); } } if let Some(boolean_operation) = node.get_str("booleanOperation") { println!("{indent}booleanOperation={boolean_operation}"); } for paint_key in ["fillPaints", "strokePaints"] { if let Some(paints) = node.get_array(paint_key) { println!("{indent}{paint_key}: {paints:?}"); } } for geometry_key in ["fillGeometry", "strokeGeometry"] { let Some(geometries) = node.get_array(geometry_key) else { continue; }; println!("{indent}{geometry_key}: {} record(s)", geometries.len()); for (geometry_index, geometry) in geometries.iter().enumerate() { let winding = geometry.get_str("windingRule").unwrap_or("(none)"); let blob_index = geometry.get_f64("commandsBlob").map(|value| value as usize); match blob_index.and_then(|index| blobs.get(index).map(|blob| (index, blob))) { Some((index, BlobOrString::Bytes(bytes))) => { println!( "{indent} [{geometry_index}] commandsBlob={index} len={} windingRule={winding} bytes={bytes:02x?}", bytes.len() ); println!( "{indent} opcode decode: {:?}", vector_decoder::decode_figma_path_blob(bytes) ); } Some((index, BlobOrString::Str(value))) => println!( "{indent} [{geometry_index}] commandsBlob={index} string={value:?} windingRule={winding}" ), None => println!( "{indent} [{geometry_index}] commandsBlob={blob_index:?} missing windingRule={winding}" ), } } } let vector_blob = node .get("vectorData") .and_then(|data| data.get_f64("vectorNetworkBlob")) .map(|value| value as usize); if let Some(index) = vector_blob { match blobs.get(index) { Some(BlobOrString::Bytes(bytes)) => println!( "{indent}vectorNetworkBlob={index} len={} header={:?}", bytes.len(), (0..3) .filter_map(|word| { let offset = word * 4; bytes.get(offset..offset + 4).map(|slice| { u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]) }) }) .collect::>() ), Some(BlobOrString::Str(value)) => { println!("{indent}vectorNetworkBlob={index} string={value:?}") } None => println!("{indent}vectorNetworkBlob={index} missing"), } } let decoded = decode_figma_vector_path(node, blobs); println!("{indent}current decode: {decoded:?}"); } fn dump_subtree(node: &TreeNode, blobs: &[BlobOrString], depth: usize, max_depth: usize) { let indent = " ".repeat(depth); let figma = &node.figma; let guid = figma .get("guid") .and_then(guid_to_string) .unwrap_or_else(|| "(none)".to_string()); println!( "{indent}- guid={guid} type={} name={:?} children={}", figma.get_str("type").unwrap_or("(none)"), figma.get_str("name").unwrap_or(""), node.children.len() ); geometry_dump(figma, blobs, &format!("{indent} ")); if depth < max_depth { for child in &node.children { dump_subtree(child, blobs, depth + 1, max_depth); } } } fn dump_named_contexts( root: &TreeNode, blobs: &[BlobOrString], page_name: &str, targets: &[String], ancestor_levels: usize, max_depth: usize, ) { struct Options<'a> { blobs: &'a [BlobOrString], page_name: &'a str, targets: &'a [String], ancestor_levels: usize, max_depth: usize, } fn visit<'a>( node: &'a TreeNode, options: &Options<'_>, current_page: Option<&'a str>, stack: &mut Vec<&'a TreeNode>, ) { let current_page = if node.figma.get_str("type") == Some("CANVAS") { node.figma.get_str("name") } else { current_page }; stack.push(node); let name = node.figma.get_str("name").unwrap_or(""); let characters = node .figma .get("textData") .and_then(|data| data.get_str("characters")) .unwrap_or(""); let matched_target = options .targets .iter() .find(|target| target.as_str() == name || target.as_str() == characters); if let Some(target) = matched_target .filter(|_| options.page_name == "*" || current_page == Some(options.page_name)) { let context_index = stack.len().saturating_sub(1 + options.ancestor_levels); let context = stack[context_index]; let path = stack .iter() .map(|entry| entry.figma.get_str("name").unwrap_or("")) .collect::>() .join(" / "); println!( "\n== TARGET {target:?} (node name={name:?}, characters={characters:?}) on page {:?} ==", current_page.unwrap_or("(outside canvas)") ); println!("tree path: {path}"); println!("context ancestor levels: {}", options.ancestor_levels); dump_subtree(context, options.blobs, 0, options.max_depth); } for child in &node.children { visit(child, options, current_page, stack); } stack.pop(); } let options = Options { blobs, page_name, targets, ancestor_levels, max_depth, }; visit(root, &options, None, &mut Vec::new()); } fn layout_value(node: &FigValue, key: &str) -> String { if let Some(value) = node.get_str(key) { return value.to_string(); } if let Some(value) = node.get_f64(key) { return format!("{value:.2}"); } if let Some(value) = node.get_bool(key) { return value.to_string(); } "-".to_string() } fn layout_dump(node: &TreeNode, depth: usize, max_depth: usize) { let indent = " ".repeat(depth); let figma = &node.figma; let guid = figma .get("guid") .and_then(guid_to_string) .unwrap_or_else(|| "(none)".to_string()); let transform = figma.get("transform").and_then(FigMatrix::from_value); let (m00, m01, m02, m10, m11, m12) = transform .map(|m| (m.m00, m.m01, m.m02, m.m10, m.m11, m.m12)) .unwrap_or((0.0, 0.0, 0.0, 0.0, 0.0, 0.0)); let size = figma.get("size"); let w = size.and_then(|value| value.get_f64("x")).unwrap_or(0.0); let h = size.and_then(|value| value.get_f64("y")).unwrap_or(0.0); let characters = figma .get("textData") .and_then(|data| data.get_str("characters")) .unwrap_or(""); println!( "{indent}- guid={guid} type={} name={:?} text={characters:?} children={}", figma.get_str("type").unwrap_or("(none)"), figma.get_str("name").unwrap_or(""), node.children.len() ); println!( "{indent} transform=[{m00:.4} {m01:.4} {m02:.2}; {m10:.4} {m11:.4} {m12:.2}] size=({w:.2},{h:.2})" ); println!( "{indent} stackMode={} spacing={} primarySizing={} counterSizing={} primaryAlign={} counterAlign={}", layout_value(figma, "stackMode"), layout_value(figma, "stackSpacing"), layout_value(figma, "stackPrimarySizing"), layout_value(figma, "stackCounterSizing"), layout_value(figma, "stackPrimaryAlignItems"), layout_value(figma, "stackCounterAlignItems") ); println!( "{indent} padding=[top:{} right:{} bottom:{} left:{} uniform:{} h:{} v:{}] textAutoResize={} constraints=({}, {})", layout_value(figma, "stackPaddingTop"), layout_value(figma, "stackPaddingRight"), layout_value(figma, "stackPaddingBottom"), layout_value(figma, "stackPaddingLeft"), layout_value(figma, "stackPadding"), layout_value(figma, "stackHorizontalPadding"), layout_value(figma, "stackVerticalPadding"), layout_value(figma, "textAutoResize"), layout_value(figma, "horizontalConstraint"), layout_value(figma, "verticalConstraint") ); if figma.get_str("type") == Some("TEXT") { let font_name = figma.get("fontName"); println!( "{indent} textStyle=family:{:?} style:{:?} size:{} lineHeight:{:?} letterSpacing:{:?} align=({},{})", font_name.and_then(|value| value.get_str("family")), font_name.and_then(|value| value.get_str("style")), layout_value(figma, "fontSize"), figma.get("lineHeight"), figma.get("letterSpacing"), layout_value(figma, "textAlignHorizontal"), layout_value(figma, "textAlignVertical") ); } if let Some(image) = figma.get_array("fillPaints").and_then(|paints| { paints .iter() .find(|paint| paint.get_str("type") == Some("IMAGE")) }) { let transform = image.get("transform").and_then(FigMatrix::from_value); println!( "{indent} image mode={} original=({},{}) transform={:?}", layout_value(image, "imageScaleMode"), layout_value(image, "originalImageWidth"), layout_value(image, "originalImageHeight"), transform.map(|m| [m.m00, m.m01, m.m02, m.m10, m.m11, m.m12]) ); } if depth < max_depth { for child in &node.children { layout_dump(child, depth + 1, max_depth); } } } struct LayoutProbeOptions<'a> { page_name: &'a str, targets: &'a [String], guids: &'a [String], target_size: Option<(f64, f64)>, ancestor_levels: usize, max_depth: usize, match_limit: usize, } fn dump_layout_contexts(root: &TreeNode, options: &LayoutProbeOptions<'_>) { fn visit<'a>( node: &'a TreeNode, options: &LayoutProbeOptions<'_>, current_page: Option<&'a str>, stack: &mut Vec<&'a TreeNode>, matches: &mut usize, ) { if *matches >= options.match_limit { return; } let current_page = if node.figma.get_str("type") == Some("CANVAS") { node.figma.get_str("name") } else { current_page }; stack.push(node); let name = node.figma.get_str("name").unwrap_or(""); let characters = node .figma .get("textData") .and_then(|data| data.get_str("characters")) .unwrap_or(""); let guid = node.figma.get("guid").and_then(guid_to_string); let size = node.figma.get("size"); let size_matches = options.target_size.is_some_and(|(target_w, target_h)| { let w = size .and_then(|value| value.get_f64("x")) .unwrap_or(f64::NAN); let h = size .and_then(|value| value.get_f64("y")) .unwrap_or(f64::NAN); (w - target_w).abs() < 0.01 && (h - target_h).abs() < 0.01 }); let matched = options .targets .iter() .any(|target| target == name || target == characters) || guid .as_ref() .is_some_and(|guid| options.guids.iter().any(|target| target == guid)) || size_matches; if matched && (options.page_name == "*" || current_page == Some(options.page_name)) { *matches += 1; let context_index = stack.len().saturating_sub(1 + options.ancestor_levels); let context = stack[context_index]; let path = stack .iter() .map(|entry| entry.figma.get_str("name").unwrap_or("")) .collect::>() .join(" / "); println!( "\n== LAYOUT MATCH {} on page {:?} ==", guid.as_deref().unwrap_or(name), current_page.unwrap_or("(outside canvas)") ); println!("tree path: {path}"); layout_dump(context, 0, options.max_depth); } for child in &node.children { visit(child, options, current_page, stack, matches); } stack.pop(); } visit(root, options, None, &mut Vec::new(), &mut 0); } fn main() { let args: Vec = std::env::args().skip(1).collect(); let path = args .iter() .find(|arg| !arg.starts_with("--")) .expect("usage: probe_vec [--page NAME] [--target NAME]..."); let page_name = args .windows(2) .find(|pair| pair[0] == "--page") .map(|pair| pair[1].as_str()) .unwrap_or("v1.0"); let targets: Vec = args .windows(2) .filter(|pair| pair[0] == "--target") .map(|pair| pair[1].clone()) .collect(); let guids: Vec = args .windows(2) .filter(|pair| pair[0] == "--guid") .map(|pair| pair[1].clone()) .collect(); let target_size = args .windows(2) .find(|pair| pair[0] == "--size") .and_then(|pair| pair[1].split_once('x')) .and_then(|(w, h)| Some((w.parse().ok()?, h.parse().ok()?))); let layout_only = args.iter().any(|arg| arg == "--layout"); let blob_indices: Vec = args .windows(2) .filter(|pair| pair[0] == "--blob") .filter_map(|pair| pair[1].parse().ok()) .collect(); let ancestor_levels = args .windows(2) .find(|pair| pair[0] == "--ancestor") .and_then(|pair| pair[1].parse().ok()) .unwrap_or(1); let max_depth = args .windows(2) .find(|pair| pair[0] == "--depth") .and_then(|pair| pair[1].parse().ok()) .unwrap_or(4); let match_limit = args .windows(2) .find(|pair| pair[0] == "--limit") .and_then(|pair| pair[1].parse().ok()) .unwrap_or(20); let bytes = std::fs::read(path).expect("read"); let decoded = match parse_fig_file(&bytes) { Ok(d) => d, Err(e) => { eprintln!("parse error: {e:?}"); std::process::exit(1); } }; println!( "node_changes: {} blobs: {}", decoded.node_changes.len(), decoded.blobs.len() ); let mut blob_bytes = 0usize; let mut blob_strs = 0usize; for b in &decoded.blobs { match b { BlobOrString::Bytes(_) => blob_bytes += 1, BlobOrString::Str(_) => blob_strs += 1, } } println!("blob kinds: bytes={blob_bytes} str={blob_strs}"); if !blob_indices.is_empty() { for index in blob_indices { match decoded.blobs.get(index) { Some(BlobOrString::Bytes(bytes)) => { let hex = bytes .iter() .map(|byte| format!("{byte:02x}")) .collect::(); println!("blob[{index}] len={} hex={hex}", bytes.len()); } Some(BlobOrString::Str(value)) => println!("blob[{index}] string={value:?}"), None => println!("blob[{index}] missing"), } } return; } if layout_only && (!targets.is_empty() || !guids.is_empty() || target_size.is_some()) { let root = build_tree(&decoded.node_changes).expect("document tree"); let options = LayoutProbeOptions { page_name, targets: &targets, guids: &guids, target_size, ancestor_levels, max_depth, match_limit, }; dump_layout_contexts(&root, &options); return; } if !targets.is_empty() { let root = build_tree(&decoded.node_changes).expect("document tree"); dump_named_contexts( &root, &decoded.blobs, page_name, &targets, ancestor_levels, max_depth, ); return; } // Global tallies. let mut type_tally: BTreeMap = BTreeMap::new(); let mut effect_tally: BTreeMap = BTreeMap::new(); let mut corner_tally: BTreeMap = BTreeMap::new(); let mut bool_op_tally: BTreeMap = BTreeMap::new(); let mut arc_data_count = 0usize; let mut ellipse_count = 0usize; // Vector-node failure classification. let mut vec_total = 0usize; let mut vec_class: BTreeMap = BTreeMap::new(); let mut fail_samples: Vec<(String, String, Vec)> = Vec::new(); let mut fail_field_tally: BTreeMap = BTreeMap::new(); const VEC_TYPES: [&str; 4] = ["VECTOR", "STAR", "REGULAR_POLYGON", "BOOLEAN_OPERATION"]; for nc in &decoded.node_changes { let ty = nc.get_str("type").unwrap_or("(none)").to_string(); bump(&mut type_tally, &ty); if let Some(effects) = nc.get_array("effects") { for e in effects { let ety = e.get_str("type").unwrap_or("(untyped)"); let vis = if e.get_bool("visible") == Some(false) { "hidden" } else { "visible" }; bump(&mut effect_tally, &format!("{ety}/{vis}")); } } if nc.get_f64("cornerRadius").map(|v| v > 0.0) == Some(true) { bump(&mut corner_tally, &format!("{ty}/cornerRadius>0")); } if nc.get_bool("rectangleCornerRadiiIndependent") == Some(true) { bump(&mut corner_tally, &format!("{ty}/perCornerRadii")); } if nc.get_f64("cornerSmoothing").map(|v| v > 0.0) == Some(true) { bump(&mut corner_tally, &format!("{ty}/cornerSmoothing>0")); } if ty == "ELLIPSE" { ellipse_count += 1; if nc.get("arcData").is_some() { arc_data_count += 1; } } if ty == "BOOLEAN_OPERATION" { let op = nc.get_str("booleanOperation").unwrap_or("(none)"); bump(&mut bool_op_tally, op); } if !VEC_TYPES.contains(&ty.as_str()) { continue; } vec_total += 1; let fill_geo = nc.get_array("fillGeometry"); let stroke_geo = nc.get_array("strokeGeometry"); let vn = nc .get("vectorData") .and_then(|v| v.get("vectorNetworkBlob")) .is_some(); let fill_n = fill_geo.map(|g| g.len()).unwrap_or(0); let stroke_n = stroke_geo.map(|g| g.len()).unwrap_or(0); for g in fill_geo .unwrap_or(&[]) .iter() .chain(stroke_geo.unwrap_or(&[])) { let wr = g.get_str("windingRule").unwrap_or("(none)"); bump(&mut corner_tally, &format!("windingRule={wr}")); } // Geometry-entry field check: does any entry carry commandsBlob? let mut geo_has_commands = false; let mut geo_blob_oob = false; let mut geo_blob_is_str = false; let mut geo_blob_short = false; for g in fill_geo .unwrap_or(&[]) .iter() .chain(stroke_geo.unwrap_or(&[])) { if let Some(idx) = g.get_f64("commandsBlob") { geo_has_commands = true; match decoded.blobs.get(idx as usize) { Some(BlobOrString::Bytes(b)) => { if b.len() < 9 { geo_blob_short = true; } } Some(BlobOrString::Str(_)) => geo_blob_is_str = true, None => geo_blob_oob = true, } } } let decoded_path = decode_figma_vector_path(nc, &decoded.blobs); let ok = decoded_path.as_deref().map(|d| !d.is_empty()) == Some(true); let geo_desc = format!( "fillGeo={} strokeGeo={} cmdBlob={} vn={}", fill_n, stroke_n, geo_has_commands, vn ); let class = if ok { format!("OK ({ty}) [{geo_desc}]") } else { let reason = if fill_n == 0 && stroke_n == 0 && !vn { "FAIL: no geometry arrays + no vectorNetworkBlob" } else if fill_n + stroke_n > 0 && !geo_has_commands { "FAIL: geometry entries lack commandsBlob field" } else if geo_blob_oob { "FAIL: commandsBlob index out of blob range" } else if geo_blob_is_str { "FAIL: commandsBlob points at string blob" } else if geo_blob_short { "FAIL: blob < 9 bytes (decoder minimum)" } else if vn { "FAIL: vectorNetworkBlob present but decode failed" } else { "FAIL: other" }; format!("{reason} ({ty})") }; bump(&mut vec_class, &class); if !ok && fail_samples.len() < 12 { let name = nc.get_str("name").unwrap_or("").to_string(); fail_samples.push((ty.clone(), name, keys(nc))); } if !ok { for k in keys(nc) { bump(&mut fail_field_tally, &k); } } } println!("\n== node type tally (top 25) =="); let mut tv: Vec<_> = type_tally.iter().collect(); tv.sort_by(|a, b| b.1.cmp(a.1)); for (k, v) in tv.iter().take(25) { println!(" {v:>6} {k}"); } println!("\n== vector-family nodes: {vec_total} =="); for (k, v) in &vec_class { println!(" {v:>6} {k}"); } println!("\n== effects tally =="); for (k, v) in &effect_tally { println!(" {v:>6} {k}"); } println!("\n== corner tally =="); for (k, v) in &corner_tally { println!(" {v:>6} {k}"); } println!("\n== boolean ops =="); for (k, v) in &bool_op_tally { println!(" {v:>6} {k}"); } println!("\nellipses: {ellipse_count} (with arcData: {arc_data_count})"); println!("\n== failing-node field frequency (top 30) =="); let mut fv: Vec<_> = fail_field_tally.iter().collect(); fv.sort_by(|a, b| b.1.cmp(a.1)); for (k, v) in fv.iter().take(30) { println!(" {v:>6} {k}"); } println!("\n== failing samples (first 12) =="); for (ty, name, ks) in &fail_samples { println!(" [{ty}] {name:?}"); println!(" keys: {}", ks.join(",")); } }