fix(figma): decode vector networks with the real kiwi blob layout

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.
This commit is contained in:
Kayshen-X 2026-07-18 15:43:24 +08:00
parent 82611f0e9f
commit c2cfce221c
5 changed files with 520 additions and 39 deletions

View file

@ -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 -- <canvas.fig>
#[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<String> {
match v {
FigValue::Object(pairs) => pairs.iter().map(|(k, _)| k.clone()).collect(),
_ => Vec::new(),
}
}
fn bump(map: &mut BTreeMap<String, usize>, key: &str) {
*map.entry(key.to_string()).or_insert(0) += 1;
}
fn main() {
let path = std::env::args().nth(1).expect("usage: probe_vec <path>");
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<String, usize> = BTreeMap::new();
let mut effect_tally: BTreeMap<String, usize> = BTreeMap::new();
let mut corner_tally: BTreeMap<String, usize> = BTreeMap::new();
let mut bool_op_tally: BTreeMap<String, usize> = 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<String, usize> = BTreeMap::new();
let mut fail_samples: Vec<(String, String, Vec<String>)> = Vec::new();
let mut fail_field_tally: BTreeMap<String, usize> = 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(","));
}
}

View file

@ -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 -- <canvas.fig> [--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<u32> {
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<f32> {
b.get(o..o + 4)
.map(|s| f32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
fn keys(v: &FigValue) -> Vec<String> {
match v {
FigValue::Object(pairs) => pairs.iter().map(|(k, _)| k.clone()).collect(),
_ => Vec::new(),
}
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let dump_smallest = args
.windows(2)
.find(|pair| pair[0] == "--dump-smallest")
.and_then(|pair| pair[1].parse::<usize>().ok())
.unwrap_or(0);
let path = args
.iter()
.find(|arg| !arg.starts_with("--") && arg.parse::<usize>().is_err())
.expect("usage: probe_vn <path> [--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<u8>)> = 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::<Vec<_>>()
);
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

View file

@ -525,4 +525,5 @@ pub fn decode_message(schema: &Schema, data: &[u8]) -> Result<FigValue, KiwiErro
}
#[cfg(test)]
#[path = "kiwi/tests.rs"]
mod tests;

View file

@ -262,39 +262,43 @@ pub fn decode_vector_network_blob(node: &FigValue, blobs: &[BlobOrString]) -> 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<VnSegment> = Vec::new();
let mut segments: Vec<VnSegment> = 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;

View file

@ -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<BlobOrString>) {
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]