openpencil/crates/op-figma/examples/probe_layout.rs

388 lines
14 KiB
Rust

//! Preserve-mode layout probe for a real `.fig` file.
//!
//! Prints converted parent-local geometry beside the absolute geometry
//! consumed by the renderer, and can raster the matched subtree through the
//! same scene painter used by the desktop canvas.
//!
//! Usage:
//! `cargo run -p op-figma --example probe_layout -- <file.fig> --page <name> --target <name> [--ancestor N] [--depth N] [--shot out.png]`
use jian_ops_schema::node::base::PenNodeBase;
use jian_ops_schema::node::PenNode;
use jian_ops_schema::sizing::SizingBehavior;
use op_editor_ui::layout_scene::{SceneNode, ScenePage};
use op_figma::{parse_fig_binary, FigLayoutMode};
use op_host_services::export::{export_node_raster, RasterFormat};
#[derive(Debug)]
struct Options {
path: String,
page: String,
target: Option<String>,
path_contains: Option<String>,
size: Option<(f64, f64)>,
ancestor: usize,
depth: usize,
limit: usize,
shot: Option<String>,
}
fn main() {
let options = options();
let bytes = std::fs::read(&options.path).expect("read fig");
let import = parse_fig_binary(&bytes, "tesla-layout-probe", FigLayoutMode::Preserve)
.expect("parse Preserve import");
println!("warnings={}", import.warnings.len());
let pages = import.document.pages.as_deref().unwrap_or(&[]);
let page_index = pages
.iter()
.position(|page| page.name == options.page)
.unwrap_or_else(|| panic!("page {:?} not found", options.page));
let page = &pages[page_index];
let mut state = op_editor_core::EditorState::from_document(import.document.clone());
state.ui.active_page_index = page_index;
state.editor_ui.preserve_authored_geometry = true;
let scene = op_pen_loader::editor_state_to_active_page_layout_scene(&state);
let scene_page = scene.active_page().expect("active scene page");
let mut stack = Vec::new();
let mut matches = Vec::new();
for root in &page.children {
find_matches(root, &options, &mut stack, &mut matches);
}
println!("matches={}", matches.len());
for (match_index, path) in matches.iter().enumerate() {
let context_index = path.len().saturating_sub(1 + options.ancestor);
let context = path[context_index];
let parent_origin = path[..context_index]
.iter()
.fold((0.0, 0.0), |(x, y), node| {
let base = base_of(node);
(x + base.x.unwrap_or(0.0), y + base.y.unwrap_or(0.0))
});
let names = path
.iter()
.map(|node| base_of(node).name.as_deref().unwrap_or("(unnamed)"))
.collect::<Vec<_>>()
.join(" / ");
println!("\n== CONVERTED MATCH {} ==", match_index + 1);
println!("path: {names}");
dump_node(context, parent_origin, scene_page, 0, options.depth);
}
if let Some(target) = options.shot.as_deref() {
let path = matches.first().expect("--shot requires at least one match");
let context_index = path.len().saturating_sub(1 + options.ancestor);
let id = &base_of(path[context_index]).id;
let target = std::path::Path::new(target);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("create shot directory");
}
export_node_raster(&scene, id, target, RasterFormat::Png, 2.0).expect("render shot");
println!("shot={}", target.display());
}
}
fn options() -> Options {
let args = std::env::args().skip(1).collect::<Vec<_>>();
let path = args
.iter()
.find(|arg| !arg.starts_with("--"))
.cloned()
.expect("probe_layout <file.fig> --page <name> (--target <name> | --size WxH)");
let value = |flag: &str| {
args.windows(2)
.find(|pair| pair[0] == flag)
.map(|pair| pair[1].clone())
};
let size = value("--size")
.and_then(|value| {
value
.split_once('x')
.map(|(w, h)| (w.to_string(), h.to_string()))
})
.and_then(|(w, h)| Some((w.parse().ok()?, h.parse().ok()?)));
Options {
path,
page: value("--page").unwrap_or_else(|| "特斯拉专修首页 v1.0".into()),
target: value("--target"),
path_contains: value("--path-contains"),
size,
ancestor: value("--ancestor")
.and_then(|value| value.parse().ok())
.unwrap_or(0),
depth: value("--depth")
.and_then(|value| value.parse().ok())
.unwrap_or(3),
limit: value("--limit")
.and_then(|value| value.parse().ok())
.unwrap_or(10),
shot: value("--shot"),
}
}
fn find_matches<'a>(
node: &'a PenNode,
options: &Options,
stack: &mut Vec<&'a PenNode>,
matches: &mut Vec<Vec<&'a PenNode>>,
) {
if matches.len() >= options.limit {
return;
}
stack.push(node);
let base = base_of(node);
let name_matches = options
.target
.as_ref()
.is_some_and(|target| base.name.as_deref() == Some(target));
let size_matches = options.size.is_some_and(|(target_w, target_h)| {
let (w, h) = numeric_size(node);
(w - target_w).abs() < 0.01 && (h - target_h).abs() < 0.01
});
let path_matches = options.path_contains.as_ref().is_none_or(|needle| {
stack.iter().any(|entry| {
base_of(entry)
.name
.as_deref()
.is_some_and(|name| name.contains(needle))
})
});
if (name_matches || size_matches) && path_matches {
matches.push(stack.clone());
}
for child in children_of(node) {
find_matches(child, options, stack, matches);
}
stack.pop();
}
fn dump_node(
node: &PenNode,
parent_origin: (f64, f64),
scene_page: &ScenePage,
depth: usize,
max_depth: usize,
) {
let base = base_of(node);
let local_x = base.x.unwrap_or(0.0);
let local_y = base.y.unwrap_or(0.0);
let abs_x = parent_origin.0 + local_x;
let abs_y = parent_origin.1 + local_y;
let (w, h) = numeric_size(node);
let rendered = scene_page
.find(&base.id)
.map(scene_rect)
.unwrap_or_else(|| "(missing)".to_string());
let indent = " ".repeat(depth);
println!(
"{indent}- id={} type={} name={:?} local=({local_x:.2},{local_y:.2},{w:.2},{h:.2}) converted_abs=({abs_x:.2},{abs_y:.2},{w:.2},{h:.2}) rendered={rendered}",
base.id,
variant_name(node),
base.name.as_deref().unwrap_or("")
);
println!("{indent} {}", layout_props(node));
if matches!(node, PenNode::Text(_)) {
if let Some(scene_node) = scene_page.find(&base.id) {
println!(
"{indent} renderedText=family:{:?} size:{:.2} weight:{} lineHeight:{:.3} letterSpacing:{:.2}",
scene_node.font_family,
scene_node.font_size,
scene_node.font_weight,
scene_node.line_height,
scene_node.letter_spacing
);
}
}
if depth < max_depth {
for child in children_of(node) {
dump_node(child, (abs_x, abs_y), scene_page, depth + 1, max_depth);
}
}
}
fn scene_rect(node: &SceneNode) -> String {
let bounds = node.bounds;
format!(
"({:.2},{:.2},{:.2},{:.2})",
bounds.origin.x, bounds.origin.y, bounds.size.x, bounds.size.y
)
}
fn layout_props(node: &PenNode) -> String {
let base = match node {
PenNode::Frame(node) => format!(
"layout={:?} gap={:?} padding={:?} justify={:?} align={:?}",
node.container.layout,
node.container.gap,
node.container.padding,
node.container.justify_content,
node.container.align_items
),
PenNode::Group(node) => format!(
"layout={:?} gap={:?} padding={:?} justify={:?} align={:?}",
node.container.layout,
node.container.gap,
node.container.padding,
node.container.justify_content,
node.container.align_items
),
PenNode::Rectangle(node) => format!(
"layout={:?} gap={:?} padding={:?} justify={:?} align={:?}",
node.container.layout,
node.container.gap,
node.container.padding,
node.container.justify_content,
node.container.align_items
),
PenNode::Text(node) => format!(
"textGrowth={:?} align={:?}/{:?} family={:?} fontSize={:?} weight={:?} lineHeight={:?} letterSpacing={:?}",
node.text_growth,
node.text_align,
node.text_align_vertical,
node.font_family,
node.font_size,
node.font_weight,
node.line_height,
node.letter_spacing
),
_ => "layout=-".to_string(),
};
match image_fill(node) {
Some(image) => format!(
"{base} imageMode={:?} original={:?} transform={:?}",
image.mode, image.original_size, image.transform
),
None => base,
}
}
fn image_fill(node: &PenNode) -> Option<&jian_ops_schema::style::ImageFillBody> {
use jian_ops_schema::style::PenFill;
let fills = match node {
PenNode::Frame(node) => node.container.fill.as_deref(),
PenNode::Group(node) => node.container.fill.as_deref(),
PenNode::Rectangle(node) => node.container.fill.as_deref(),
PenNode::Ellipse(node) => node.fill.as_deref(),
PenNode::Polygon(node) => node.fill.as_deref(),
PenNode::Path(node) => node.fill.as_deref(),
PenNode::Text(node) => node.fill.as_deref(),
_ => None,
}?;
fills.iter().find_map(|fill| match fill {
PenFill::Image(image) => Some(image),
_ => None,
})
}
fn numeric_size(node: &PenNode) -> (f64, f64) {
let (width, height) = sizing(node);
(numeric(width), numeric(height))
}
fn numeric(sizing: Option<&SizingBehavior>) -> f64 {
match sizing {
Some(SizingBehavior::Number(value)) => *value,
_ => 0.0,
}
}
fn sizing(node: &PenNode) -> (Option<&SizingBehavior>, Option<&SizingBehavior>) {
match node {
PenNode::Frame(node) => (
node.container.width.as_ref(),
node.container.height.as_ref(),
),
PenNode::Group(node) => (
node.container.width.as_ref(),
node.container.height.as_ref(),
),
PenNode::Rectangle(node) => (
node.container.width.as_ref(),
node.container.height.as_ref(),
),
PenNode::Ellipse(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::Polygon(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::Path(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::Text(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::TextInput(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::TextArea(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::Select(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::Switch(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::Checkbox(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::Slider(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::RadioGroup(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::NumberInput(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::Progress(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::Tabs(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::Image(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::IconFont(node) => (node.width.as_ref(), node.height.as_ref()),
PenNode::Line(_) | PenNode::Ref(_) => (None, None),
}
}
fn children_of(node: &PenNode) -> &[PenNode] {
match node {
PenNode::Frame(node) => node.children.as_deref().unwrap_or(&[]),
PenNode::Group(node) => node.children.as_deref().unwrap_or(&[]),
PenNode::Rectangle(node) => node.children.as_deref().unwrap_or(&[]),
PenNode::Tabs(node) => node.children.as_deref().unwrap_or(&[]),
PenNode::Ref(node) => node.children.as_deref().unwrap_or(&[]),
_ => &[],
}
}
fn base_of(node: &PenNode) -> &PenNodeBase {
match node {
PenNode::Frame(node) => &node.base,
PenNode::Group(node) => &node.base,
PenNode::Rectangle(node) => &node.base,
PenNode::Ellipse(node) => &node.base,
PenNode::Line(node) => &node.base,
PenNode::Polygon(node) => &node.base,
PenNode::Path(node) => &node.base,
PenNode::Text(node) => &node.base,
PenNode::TextInput(node) => &node.base,
PenNode::TextArea(node) => &node.base,
PenNode::Select(node) => &node.base,
PenNode::Switch(node) => &node.base,
PenNode::Checkbox(node) => &node.base,
PenNode::Slider(node) => &node.base,
PenNode::RadioGroup(node) => &node.base,
PenNode::NumberInput(node) => &node.base,
PenNode::Progress(node) => &node.base,
PenNode::Tabs(node) => &node.base,
PenNode::Image(node) => &node.base,
PenNode::IconFont(node) => &node.base,
PenNode::Ref(node) => &node.base,
}
}
fn variant_name(node: &PenNode) -> &'static str {
match node {
PenNode::Frame(_) => "frame",
PenNode::Group(_) => "group",
PenNode::Rectangle(_) => "rectangle",
PenNode::Ellipse(_) => "ellipse",
PenNode::Line(_) => "line",
PenNode::Polygon(_) => "polygon",
PenNode::Path(_) => "path",
PenNode::Text(_) => "text",
PenNode::TextInput(_) => "text_input",
PenNode::TextArea(_) => "text_area",
PenNode::Select(_) => "select",
PenNode::Switch(_) => "switch",
PenNode::Checkbox(_) => "checkbox",
PenNode::Slider(_) => "slider",
PenNode::RadioGroup(_) => "radio_group",
PenNode::NumberInput(_) => "number_input",
PenNode::Progress(_) => "progress",
PenNode::Tabs(_) => "tabs",
PenNode::Image(_) => "image",
PenNode::IconFont(_) => "icon_font",
PenNode::Ref(_) => "ref",
}
}