699 lines
25 KiB
Rust
699 lines
25 KiB
Rust
//! 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/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<String> {
|
|
match v {
|
|
FigValue::Object(pairs) => pairs.iter().map(|(k, _)| k.to_string()).collect(),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn bump(map: &mut BTreeMap<String, usize>, 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::<Vec<_>>()
|
|
),
|
|
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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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<String> = std::env::args().skip(1).collect();
|
|
let path = args
|
|
.iter()
|
|
.find(|arg| !arg.starts_with("--"))
|
|
.expect("usage: probe_vec <path> [--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<String> = args
|
|
.windows(2)
|
|
.filter(|pair| pair[0] == "--target")
|
|
.map(|pair| pair[1].clone())
|
|
.collect();
|
|
let guids: Vec<String> = 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<usize> = 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::<String>();
|
|
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<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(","));
|
|
}
|
|
}
|