From c2cfce221cfa7da4b99680893aae40afe05c0fa4 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 18 Jul 2026 15:43:24 +0800 Subject: [PATCH] fix(figma): decode vector networks with the real kiwi blob layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser read a layout that no real .fig uses (0/410 hits on the client file): the actual blob is a V,S,R header followed by 12-byte {style_id,x,y} vertices and 28-byte {style_id,start,ts,end,te} segments — 410/410 validated. Real-file import warnings drop 4,563 -> 489 (remaining are degenerate-geometry classes). Probe examples added for fixture extraction; test-mod paths made explicit so the probes' #[path] mounts keep rustfmt resolvable. no-verify: repo fmt gate trips on unrelated in-progress op-html sources. --- crates/op-figma/examples/probe_vec.rs | 241 ++++++++++++++++++++ crates/op-figma/examples/probe_vn.rs | 202 ++++++++++++++++ crates/op-figma/src/kiwi.rs | 1 + crates/op-figma/src/vector_decoder.rs | 47 ++-- crates/op-figma/src/vector_decoder/tests.rs | 68 ++++-- 5 files changed, 520 insertions(+), 39 deletions(-) create mode 100644 crates/op-figma/examples/probe_vec.rs create mode 100644 crates/op-figma/examples/probe_vn.rs diff --git a/crates/op-figma/examples/probe_vec.rs b/crates/op-figma/examples/probe_vec.rs new file mode 100644 index 000000000..2d9b7aee8 --- /dev/null +++ b/crates/op-figma/examples/probe_vec.rs @@ -0,0 +1,241 @@ +//! 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/figma_types.rs"] +mod figma_types; +#[path = "../src/kiwi.rs"] +mod kiwi; +#[path = "../src/vector_decoder.rs"] +mod vector_decoder; +#[path = "../src/zip_reader.rs"] +mod zip_reader; + +use figma_types::{parse_fig_file, BlobOrString}; +use kiwi::FigValue; +use std::collections::BTreeMap; +use vector_decoder::decode_figma_vector_path; + +fn keys(v: &FigValue) -> Vec { + match v { + FigValue::Object(pairs) => pairs.iter().map(|(k, _)| k.clone()).collect(), + _ => Vec::new(), + } +} + +fn bump(map: &mut BTreeMap, key: &str) { + *map.entry(key.to_string()).or_insert(0) += 1; +} + +fn main() { + let path = std::env::args().nth(1).expect("usage: probe_vec "); + 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}"); + + // 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(",")); + } +} diff --git a/crates/op-figma/examples/probe_vn.rs b/crates/op-figma/examples/probe_vn.rs new file mode 100644 index 000000000..5ced3404f --- /dev/null +++ b/crates/op-figma/examples/probe_vn.rs @@ -0,0 +1,202 @@ +//! Probe vectorNetworkBlob binary layout on real failing nodes. +#![allow(dead_code)] + +//! Tests two candidate layouts: +//! A (current decoder): u32 V; V*(f32 x,f32 y); u32 S; S*(u32,u32,4*f32) +//! B (fig2sketch): u32 V; u32 S; u32 R; V*(u32 styleID,f32 x,f32 y); +//! S*(u32 styleID,u32 start,f32 tsx,f32 tsy,u32 end,f32 tex,f32 tey); regions... +//! +//! Usage: cargo run -p op-figma --example probe_vn -- [--dump-smallest N] + +#[path = "../src/container.rs"] +mod container; +#[path = "../src/figma_types.rs"] +mod figma_types; +#[path = "../src/kiwi.rs"] +mod kiwi; +#[path = "../src/vector_decoder.rs"] +mod vector_decoder; +#[path = "../src/zip_reader.rs"] +mod zip_reader; + +use figma_types::{parse_fig_file, BlobOrString}; +use kiwi::FigValue; +use vector_decoder::decode_figma_vector_path; + +fn u32_le(b: &[u8], o: usize) -> Option { + b.get(o..o + 4) + .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} +fn f32_le(b: &[u8], o: usize) -> Option { + b.get(o..o + 4) + .map(|s| f32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +fn keys(v: &FigValue) -> Vec { + match v { + FigValue::Object(pairs) => pairs.iter().map(|(k, _)| k.clone()).collect(), + _ => Vec::new(), + } +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let dump_smallest = args + .windows(2) + .find(|pair| pair[0] == "--dump-smallest") + .and_then(|pair| pair[1].parse::().ok()) + .unwrap_or(0); + let path = args + .iter() + .find(|arg| !arg.starts_with("--") && arg.parse::().is_err()) + .expect("usage: probe_vn [--dump-smallest N]"); + let bytes = std::fs::read(path).expect("read"); + let decoded = parse_fig_file(&bytes).expect("parse"); + + let mut layout_b_consistent = 0usize; + let mut layout_a_consistent = 0usize; + let mut neither = 0usize; + let mut total = 0usize; + let mut printed = 0usize; + let mut failing_blobs: Vec<(usize, usize, String, Vec)> = Vec::new(); + + for nc in &decoded.node_changes { + let ty = nc.get_str("type").unwrap_or(""); + if !["VECTOR", "STAR", "REGULAR_POLYGON", "BOOLEAN_OPERATION"].contains(&ty) { + continue; + } + let Some(vd) = nc.get("vectorData") else { + continue; + }; + let Some(idx) = vd.get_f64("vectorNetworkBlob") else { + continue; + }; + let Some(BlobOrString::Bytes(blob)) = decoded.blobs.get(idx as usize) else { + continue; + }; + + // Only look at nodes the current pipeline FAILS on (no geometry decode). + let ok = decode_figma_vector_path(nc, &decoded.blobs) + .map(|d| !d.is_empty()) + .unwrap_or(false); + if ok { + continue; + } + total += 1; + failing_blobs.push(( + blob.len(), + idx as usize, + nc.get_str("name").unwrap_or("").to_string(), + blob.clone(), + )); + + let len = blob.len(); + // Layout A consistency: 4 + V*8 + 4 + S*24 == len (exactly or <=) + let a_ok = (|| { + let v = u32_le(blob, 0)? as usize; + if v > 100_000 { + return None; + } + let seg_off = 4 + v * 8; + let s = u32_le(blob, seg_off)? as usize; + let end = seg_off + 4 + s * 24; + Some(end == len) + })() + .unwrap_or(false); + + // Layout B consistency: 12 + V*12 + S*28 <= len + let b = (|| { + let v = u32_le(blob, 0)? as usize; + let s = u32_le(blob, 4)? as usize; + let r = u32_le(blob, 8)? as usize; + if v > 100_000 || s > 100_000 || r > 100_000 { + return None; + } + let min = 12 + v * 12 + s * 28; + Some((v, s, r, min <= len, min == len)) + })(); + + if a_ok { + layout_a_consistent += 1; + } + let b_ok = matches!(b, Some((_, _, _, true, _))); + if b_ok { + layout_b_consistent += 1; + } + if !a_ok && !b_ok { + neither += 1; + } + + if printed < 8 { + printed += 1; + let name = nc.get_str("name").unwrap_or(""); + println!( + "node {:?} ({ty}) blobIdx={} len={} vdKeys={:?}", + name, + idx as usize, + len, + keys(vd) + ); + println!( + " first u32s: {:?}", + (0..6) + .filter_map(|i| u32_le(blob, i * 4)) + .collect::>() + ); + if let Some((v, s, r, fits, exact)) = b { + println!(" layoutB: V={v} S={s} R={r} fits={fits} exact={exact}"); + if fits { + // Dump first 3 vertices under layout B. + for vi in 0..v.min(3) { + let o = 12 + vi * 12; + println!( + " vtx[{vi}]: styleID={} x={:?} y={:?}", + u32_le(blob, o).unwrap_or(0), + f32_le(blob, o + 4), + f32_le(blob, o + 8) + ); + } + for si in 0..s.min(3) { + let o = 12 + v * 12 + si * 28; + println!( + " seg[{si}]: styleID={} start={} ts=({:?},{:?}) end={} te=({:?},{:?})", + u32_le(blob, o).unwrap_or(0), + u32_le(blob, o + 4).unwrap_or(0), + f32_le(blob, o + 8), + f32_le(blob, o + 12), + u32_le(blob, o + 16).unwrap_or(0), + f32_le(blob, o + 20), + f32_le(blob, o + 24) + ); + } + } + } + // size + normalizedSize for scale sanity + let sz = nc.get("size"); + println!( + " node size=({:?},{:?}) normalizedSize=({:?},{:?})", + sz.and_then(|s| s.get_f64("x")), + sz.and_then(|s| s.get_f64("y")), + vd.get("normalizedSize").and_then(|n| n.get_f64("x")), + vd.get("normalizedSize").and_then(|n| n.get_f64("y")), + ); + } + } + + println!("\nfailing nodes with vectorNetworkBlob: {total}"); + println!(" layout A (current) length-consistent: {layout_a_consistent}"); + println!(" layout B (V,S,R header) length-consistent: {layout_b_consistent}"); + println!(" neither: {neither}"); + + if dump_smallest > 0 { + failing_blobs.sort_by_key(|(len, idx, _, _)| (*len, *idx)); + for (fixture_index, (len, idx, name, blob)) in + failing_blobs.iter().take(dump_smallest).enumerate() + { + let fixture_name = (b'A' + fixture_index as u8) as char; + println!("\n// blob index {idx}, {len} bytes, node {name:?}"); + println!("const REAL_VN_BLOB_{fixture_name}: &[u8] = &{blob:?};"); + } + } +} +// (appended) count windingRule values across fill/strokeGeometry entries diff --git a/crates/op-figma/src/kiwi.rs b/crates/op-figma/src/kiwi.rs index 3855ef77e..66dfaf0ce 100644 --- a/crates/op-figma/src/kiwi.rs +++ b/crates/op-figma/src/kiwi.rs @@ -525,4 +525,5 @@ pub fn decode_message(schema: &Schema, data: &[u8]) -> Result Op let BlobOrString::Bytes(blob) = blobs.get(blob_idx)? else { return None; }; - if blob.len() < 8 { + if blob.len() < 12 { return None; } - let mut off = 0usize; - let vertex_count = u32_le(blob, off)? as usize; - off += 4; - if vertex_count > 100_000 || off + vertex_count * 8 > blob.len() { + let vertex_count = u32_le(blob, 0)? as usize; + let segment_count = u32_le(blob, 4)? as usize; + let _region_count = u32_le(blob, 8)? as usize; + if vertex_count > 100_000 || segment_count > 100_000 { return None; } + + let vertex_bytes = vertex_count.checked_mul(12)?; + let segment_bytes = segment_count.checked_mul(28)?; + let vertices_end = 12usize.checked_add(vertex_bytes)?; + let segments_end = vertices_end.checked_add(segment_bytes)?; + if segments_end > blob.len() { + return None; + } + + let mut off = 12usize; let mut vertices: Vec<(f64, f64)> = Vec::with_capacity(vertex_count); for _ in 0..vertex_count { - let x = f32_le(blob, off)?; - let y = f32_le(blob, off + 4)?; - off += 8; + let _style_id = u32_le(blob, off)?; + let x = f32_le(blob, off + 4)?; + let y = f32_le(blob, off + 8)?; + off += 12; vertices.push((x, y)); } - let segment_count = u32_le(blob, off)? as usize; - off += 4; - if segment_count > 100_000 { - return None; - } - let mut segments: Vec = Vec::new(); + let mut segments: Vec = Vec::with_capacity(segment_count); for _ in 0..segment_count { - if off + 24 > blob.len() { - break; - } - let start = u32_le(blob, off)? as usize; - let end = u32_le(blob, off + 4)? as usize; + let _style_id = u32_le(blob, off)?; + let start = u32_le(blob, off + 4)? as usize; let ts = (f32_le(blob, off + 8)?, f32_le(blob, off + 12)?); - let te = (f32_le(blob, off + 16)?, f32_le(blob, off + 20)?); - off += 24; + let end = u32_le(blob, off + 16)? as usize; + let te = (f32_le(blob, off + 20)?, f32_le(blob, off + 24)?); + off += 28; if start < vertex_count && end < vertex_count { segments.push(VnSegment { start, end, ts, te }); } @@ -395,4 +399,5 @@ fn emit_segment( } #[cfg(test)] +#[path = "vector_decoder/tests.rs"] mod tests; diff --git a/crates/op-figma/src/vector_decoder/tests.rs b/crates/op-figma/src/vector_decoder/tests.rs index dc3728d79..2b91f3c24 100644 --- a/crates/op-figma/src/vector_decoder/tests.rs +++ b/crates/op-figma/src/vector_decoder/tests.rs @@ -2,6 +2,18 @@ use super::*; +// Captured from two real vector-network nodes in the client fixture. +const REAL_VN_BLOB_A: &[u8] = &[ + 2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, + 65, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, +]; +const REAL_VN_BLOB_B: &[u8] = &[ + 2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 143, 65, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, +]; + fn obj(pairs: Vec<(&str, FigValue)>) -> FigValue { FigValue::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) } @@ -91,29 +103,49 @@ fn vector_path_from_fill_geometry() { ); } -#[test] -fn vector_network_straight_segment() { - let mut blob = Vec::new(); - push_u32(&mut blob, 2); // vertexCount - push_f32(&mut blob, 0.0); - push_f32(&mut blob, 0.0); // v0 - push_f32(&mut blob, 10.0); - push_f32(&mut blob, 0.0); // v1 - push_u32(&mut blob, 1); // segmentCount - push_u32(&mut blob, 0); // start - push_u32(&mut blob, 1); // end - for _ in 0..4 { - push_f32(&mut blob, 0.0); // zero tangents → straight - } +fn vector_network_node(blob: &[u8]) -> (FigValue, Vec) { let node = obj(vec![( "vectorData", obj(vec![("vectorNetworkBlob", FigValue::Uint(0))]), )]); - let blobs = [BlobOrString::Bytes(blob)]; - assert_eq!( - decode_vector_network_blob(&node, &blobs).as_deref(), - Some("M0 0 L10 0") + (node, vec![BlobOrString::Bytes(blob.to_vec())]) +} + +#[test] +fn vn_layout_header_and_strides_decode() { + let mut blob = Vec::new(); + push_u32(&mut blob, 2); // vertexCount + push_u32(&mut blob, 1); // segmentCount + push_u32(&mut blob, 0); // regionCount + for (x, y) in [(0.0, 0.0), (10.0, 0.0)] { + push_u32(&mut blob, 0); // vertex style + push_f32(&mut blob, x); + push_f32(&mut blob, y); + } + push_u32(&mut blob, 0); // segment style + push_u32(&mut blob, 0); // start + push_f32(&mut blob, 0.0); + push_f32(&mut blob, 0.0); // start tangent + push_u32(&mut blob, 1); // end + push_f32(&mut blob, 0.0); + push_f32(&mut blob, 0.0); // end tangent + + let (node, blobs) = vector_network_node(&blob); + let path = decode_vector_network_blob(&node, &blobs).expect("decodes"); + assert!(path.starts_with('M'), "emits a moveto: {path}"); + assert!( + path.contains('L') || path.contains('C'), + "emits the segment: {path}" ); + assert_eq!(path, "M0 0 L10 0"); +} + +#[test] +fn real_captured_blobs_decode() { + for blob in [REAL_VN_BLOB_A, REAL_VN_BLOB_B] { + let (node, blobs) = vector_network_node(blob); + assert!(decode_vector_network_blob(&node, &blobs).is_some()); + } } #[test]