use std::collections::BTreeMap;
use zenith_core::{
Dimension, FrameNode, GroupNode, KdlAdapter, KdlSource, Node, Page, PropertyValue,
ResolvedToken, ResolvedValue, Unit, resolve_tokens,
};
use crate::commands::serialize_pretty;
use crate::json_types::RecipeInspectJson;
use super::recipes;
#[derive(Debug)]
pub struct InspectCmdErr {
pub message: String,
pub exit_code: u8,
}
impl InspectCmdErr {
fn new(msg: impl Into<String>, exit_code: u8) -> Self {
Self {
message: msg.into(),
exit_code,
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct NodeGeometry {
#[serde(skip_serializing_if = "Option::is_none")]
pub x: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub y: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub w: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub h: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub x1: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub y1: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub x2: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub y2: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub point_count: Option<usize>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct NodeEntry {
pub id: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
pub geometry: Option<NodeGeometry>,
pub visible: Option<bool>,
pub locked: Option<bool>,
pub children: Vec<NodeEntry>,
}
type Resolved = BTreeMap<String, ResolvedToken>;
#[derive(Debug, Clone, serde::Serialize)]
pub struct PageEntry {
pub id: String,
pub name: Option<String>,
pub width: f64,
pub height: f64,
pub children: Vec<NodeEntry>,
}
#[derive(Debug, serde::Serialize)]
pub struct InspectOutput {
pub schema: &'static str,
pub pages: Vec<PageEntry>,
pub recipes: Vec<RecipeInspectJson>,
}
#[derive(Debug, serde::Serialize)]
pub struct InspectNodeOutput {
pub schema: &'static str,
pub node: NodeEntry,
}
pub fn run(src: &str, node_id: Option<&str>, json: bool) -> Result<String, InspectCmdErr> {
let doc = KdlAdapter
.parse(src.as_bytes())
.map_err(|e| InspectCmdErr::new(format!("error[parse.error]: {}", e.message), 2))?;
let resolved = resolve_tokens(&doc.tokens).resolved;
if let Some(id) = node_id {
let entry = find_node_tree(&doc.body.pages, id, &resolved)
.ok_or_else(|| InspectCmdErr::new(format!("error: node '{}' not found", id), 2))?;
let out = if json {
let output = InspectNodeOutput {
schema: "zenith-inspect-v1",
node: entry,
};
serialize_pretty(&output)
} else {
render_node_human(&entry, 0).trim_end().to_owned()
};
Ok(out)
} else {
let pages = build_doc_tree(&doc.body.pages, &resolved);
let out = if json {
let recipe_entries = recipes::build_recipe_entries(&doc.recipes);
let output = InspectOutput {
schema: "zenith-inspect-v1",
pages,
recipes: recipe_entries,
};
serialize_pretty(&output)
} else {
let mut text = render_pages_human(&pages);
let recipe_section = recipes::render_recipes_human(&doc.recipes);
if !recipe_section.is_empty() {
text.push('\n');
text.push('\n');
text.push_str(&recipe_section);
}
text
};
Ok(out)
}
}
pub fn summary(
src: &str,
node: Option<&str>,
depth: usize,
detail: bool,
) -> Result<serde_json::Value, InspectCmdErr> {
let doc = KdlAdapter
.parse(src.as_bytes())
.map_err(|e| InspectCmdErr::new(format!("error[parse.error]: {}", e.message), 2))?;
let resolved = resolve_tokens(&doc.tokens).resolved;
if let Some(id) = node {
let entry = find_node_tree(&doc.body.pages, id, &resolved)
.ok_or_else(|| InspectCmdErr::new(format!("error: node '{id}' not found"), 2))?;
Ok(serde_json::json!({
"schema": "zenith-inspect-summary-v1",
"node": trim_node(&entry, depth, detail),
}))
} else {
let pages = build_doc_tree(&doc.body.pages, &resolved);
let page_values: Vec<serde_json::Value> =
pages.iter().map(|p| trim_page(p, depth, detail)).collect();
Ok(serde_json::json!({
"schema": "zenith-inspect-summary-v1",
"pages": page_values,
"recipe_count": doc.recipes.len(),
}))
}
}
fn trim_page(p: &PageEntry, depth: usize, detail: bool) -> serde_json::Value {
let mut obj = serde_json::Map::new();
obj.insert("id".into(), p.id.clone().into());
if let Some(name) = &p.name {
obj.insert("name".into(), name.clone().into());
}
obj.insert("width".into(), p.width.into());
obj.insert("height".into(), p.height.into());
insert_children(&mut obj, &p.children, depth, detail);
serde_json::Value::Object(obj)
}
fn trim_node(n: &NodeEntry, depth: usize, detail: bool) -> serde_json::Value {
let mut obj = serde_json::Map::new();
obj.insert("id".into(), n.id.clone().into());
obj.insert("kind".into(), n.kind.clone().into());
if detail {
if let Some(role) = &n.role {
obj.insert("role".into(), role.clone().into());
}
if let Some(g) = &n.geometry {
obj.insert(
"geometry".into(),
serde_json::to_value(g).unwrap_or(serde_json::Value::Null),
);
}
if let Some(v) = n.visible {
obj.insert("visible".into(), v.into());
}
if let Some(l) = n.locked {
obj.insert("locked".into(), l.into());
}
}
insert_children(&mut obj, &n.children, depth, detail);
serde_json::Value::Object(obj)
}
fn insert_children(
obj: &mut serde_json::Map<String, serde_json::Value>,
children: &[NodeEntry],
depth: usize,
detail: bool,
) {
if children.is_empty() {
return;
}
if depth == 0 {
obj.insert("child_count".into(), children.len().into());
} else {
let kids: Vec<serde_json::Value> = children
.iter()
.map(|c| trim_node(c, depth - 1, detail))
.collect();
obj.insert("children".into(), serde_json::Value::Array(kids));
}
}
pub fn build_doc_tree(pages: &[Page], resolved: &Resolved) -> Vec<PageEntry> {
pages
.iter()
.map(|p| build_page_entry(p, resolved))
.collect()
}
fn build_page_entry(page: &Page, resolved: &Resolved) -> PageEntry {
PageEntry {
id: page.id.clone(),
name: page.name.clone(),
width: dim_to_f64(&page.width),
height: dim_to_f64(&page.height),
children: page
.children
.iter()
.map(|n| build_node_entry(n, resolved))
.collect(),
}
}
fn build_node_entry(node: &Node, resolved: &Resolved) -> NodeEntry {
match node {
Node::Rect(n) => NodeEntry {
id: n.id.clone(),
kind: "rect".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Ellipse(n) => NodeEntry {
id: n.id.clone(),
kind: "ellipse".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Line(n) => NodeEntry {
id: n.id.clone(),
kind: "line".into(),
role: n.role.clone(),
geometry: Some(NodeGeometry {
x: None,
y: None,
w: None,
h: None,
x1: n.x1.as_ref().map(dim_to_f64),
y1: n.y1.as_ref().map(dim_to_f64),
x2: n.x2.as_ref().map(dim_to_f64),
y2: n.y2.as_ref().map(dim_to_f64),
point_count: None,
}),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Text(n) => NodeEntry {
id: n.id.clone(),
kind: "text".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Code(n) => NodeEntry {
id: n.id.clone(),
kind: "code".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Image(n) => NodeEntry {
id: n.id.clone(),
kind: "image".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Frame(n) => NodeEntry {
id: n.id.clone(),
kind: "frame".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: n
.children
.iter()
.map(|c| build_node_entry(c, resolved))
.collect(),
},
Node::Group(n) => NodeEntry {
id: n.id.clone(),
kind: "group".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: n
.children
.iter()
.map(|c| build_node_entry(c, resolved))
.collect(),
},
Node::Polygon(n) => NodeEntry {
id: n.id.clone(),
kind: "polygon".into(),
role: n.role.clone(),
geometry: Some(NodeGeometry {
x: None,
y: None,
w: None,
h: None,
x1: None,
y1: None,
x2: None,
y2: None,
point_count: Some(n.points.len()),
}),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Polyline(n) => NodeEntry {
id: n.id.clone(),
kind: "polyline".into(),
role: n.role.clone(),
geometry: Some(NodeGeometry {
x: None,
y: None,
w: None,
h: None,
x1: None,
y1: None,
x2: None,
y2: None,
point_count: Some(n.points.len()),
}),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Instance(n) => NodeEntry {
id: n.id.clone(),
kind: "instance".into(),
role: n.role.clone(),
geometry: Some(NodeGeometry {
x: opt_dim_to_f64(n.x.as_ref()),
y: opt_dim_to_f64(n.y.as_ref()),
w: None,
h: None,
x1: None,
y1: None,
x2: None,
y2: None,
point_count: None,
}),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Field(n) => NodeEntry {
id: n.id.clone(),
kind: "field".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Toc(n) => NodeEntry {
id: n.id.clone(),
kind: "toc".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Footnote(n) => NodeEntry {
id: n.id.clone(),
kind: "footnote".into(),
role: n.role.clone(),
geometry: None,
visible: None,
locked: None,
children: vec![],
},
Node::Table(n) => NodeEntry {
id: n.id.clone(),
kind: "table".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: n
.rows
.iter()
.flat_map(|row| row.cells.iter())
.flat_map(|cell| cell.children.iter())
.map(|c| build_node_entry(c, resolved))
.collect(),
},
Node::Shape(n) => NodeEntry {
id: n.id.clone(),
kind: "shape".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Connector(n) => NodeEntry {
id: n.id.clone(),
kind: "connector".into(),
role: n.role.clone(),
geometry: None,
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Pattern(n) => NodeEntry {
id: n.id.clone(),
kind: "pattern".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Chart(n) => NodeEntry {
id: n.id.clone(),
kind: "chart".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Light(n) => NodeEntry {
id: n.id.clone(),
kind: "light".into(),
role: n.role.clone(),
geometry: light_geom(n, resolved),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Mesh(n) => NodeEntry {
id: n.id.clone(),
kind: "mesh".into(),
role: n.role.clone(),
geometry: bbox_geom(
n.x.as_ref(),
n.y.as_ref(),
n.w.as_ref(),
n.h.as_ref(),
resolved,
),
visible: n.visible,
locked: n.locked,
children: vec![],
},
Node::Unknown(n) => NodeEntry {
id: n.id.clone().unwrap_or_default(),
kind: n.kind.clone(),
role: None,
geometry: None,
visible: None,
locked: None,
children: n
.children
.iter()
.map(|c| build_node_entry(c, resolved))
.collect(),
},
}
}
pub fn find_node_tree(pages: &[Page], id: &str, resolved: &Resolved) -> Option<NodeEntry> {
for page in pages {
if let Some(entry) = search_nodes(&page.children, id, resolved) {
return Some(entry);
}
}
None
}
fn search_nodes(nodes: &[Node], id: &str, resolved: &Resolved) -> Option<NodeEntry> {
for node in nodes {
let node_id = node_id_str(node);
if node_id == id {
return Some(build_node_entry(node, resolved));
}
if let Some(children) = node_children(node)
&& let Some(found) = search_nodes(children, id, resolved)
{
return Some(found);
}
if let Node::Table(t) = node {
for row in &t.rows {
for cell in &row.cells {
if let Some(found) = search_nodes(&cell.children, id, resolved) {
return Some(found);
}
}
}
}
}
None
}
fn node_id_str(node: &Node) -> &str {
match node {
Node::Rect(n) => &n.id,
Node::Ellipse(n) => &n.id,
Node::Line(n) => &n.id,
Node::Text(n) => &n.id,
Node::Code(n) => &n.id,
Node::Frame(n) => &n.id,
Node::Group(n) => &n.id,
Node::Image(n) => &n.id,
Node::Polygon(n) => &n.id,
Node::Polyline(n) => &n.id,
Node::Instance(n) => &n.id,
Node::Field(n) => &n.id,
Node::Toc(n) => &n.id,
Node::Footnote(n) => &n.id,
Node::Table(n) => &n.id,
Node::Shape(n) => &n.id,
Node::Connector(n) => &n.id,
Node::Pattern(n) => &n.id,
Node::Chart(n) => &n.id,
Node::Light(n) => &n.id,
Node::Mesh(n) => &n.id,
Node::Unknown(n) => n.id.as_deref().unwrap_or(""),
}
}
fn node_children(node: &Node) -> Option<&[Node]> {
match node {
Node::Frame(FrameNode { children, .. }) | Node::Group(GroupNode { children, .. }) => {
Some(children)
}
Node::Unknown(n) => Some(&n.children),
Node::Rect(_)
| Node::Ellipse(_)
| Node::Line(_)
| Node::Text(_)
| Node::Code(_)
| Node::Image(_)
| Node::Polygon(_)
| Node::Polyline(_)
| Node::Instance(_)
| Node::Field(_)
| Node::Footnote(_)
| Node::Toc(_)
| Node::Table(_)
| Node::Shape(_)
| Node::Connector(_)
| Node::Pattern(_)
| Node::Chart(_)
| Node::Light(_)
| Node::Mesh(_) => None,
}
}
fn dim_to_f64(d: &Dimension) -> f64 {
match d.unit {
Unit::Pt => d.value * 96.0 / 72.0,
Unit::Px | Unit::Pct | Unit::Deg | Unit::Unknown(_) => d.value,
}
}
fn opt_dim_to_f64(d: Option<&Dimension>) -> Option<f64> {
d.map(dim_to_f64)
}
fn opt_pv_to_f64(pv: Option<&PropertyValue>, resolved: &Resolved) -> Option<f64> {
match pv? {
PropertyValue::Dimension(d) => Some(dim_to_f64(d)),
PropertyValue::TokenRef(id) => match resolved.get(id).map(|t| &t.value) {
Some(ResolvedValue::Dimension(d)) => Some(dim_to_f64(d)),
_ => None,
},
PropertyValue::Literal(_) | PropertyValue::DataRef(_) => None,
}
}
fn bbox_geom(
x: Option<&PropertyValue>,
y: Option<&PropertyValue>,
w: Option<&PropertyValue>,
h: Option<&PropertyValue>,
resolved: &Resolved,
) -> Option<NodeGeometry> {
Some(NodeGeometry {
x: opt_pv_to_f64(x, resolved),
y: opt_pv_to_f64(y, resolved),
w: opt_pv_to_f64(w, resolved),
h: opt_pv_to_f64(h, resolved),
x1: None,
y1: None,
x2: None,
y2: None,
point_count: None,
})
}
fn light_geom(n: &zenith_core::LightNode, resolved: &Resolved) -> Option<NodeGeometry> {
let x = opt_pv_to_f64(n.x.as_ref(), resolved)?;
let y = opt_pv_to_f64(n.y.as_ref(), resolved)?;
let radius = opt_pv_to_f64(n.radius.as_ref(), resolved)?;
Some(NodeGeometry {
x: Some(x - radius),
y: Some(y - radius),
w: Some(radius * 2.0),
h: Some(radius * 2.0),
x1: None,
y1: None,
x2: None,
y2: None,
point_count: None,
})
}
fn render_pages_human(pages: &[PageEntry]) -> String {
let mut out = String::new();
for page in pages {
let name_part = page
.name
.as_deref()
.map(|n| format!(" \"{}\"", n))
.unwrap_or_default();
out.push_str(&format!(
"page {}{} ({}x{})\n",
page.id, name_part, page.width, page.height
));
for child in &page.children {
out.push_str(&render_node_human(child, 1));
}
}
out.trim_end().to_owned()
}
fn render_node_human(node: &NodeEntry, depth: usize) -> String {
let indent = " ".repeat(depth);
let geom = render_geom_summary(node);
let flags = render_flags(node);
let suffix = [geom, flags]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ");
let suffix_part = if suffix.is_empty() {
String::new()
} else {
format!(" {}", suffix)
};
let mut out = format!("{}{} {}{}\n", indent, node.kind, node.id, suffix_part);
for child in &node.children {
out.push_str(&render_node_human(child, depth + 1));
}
out
}
fn render_geom_summary(node: &NodeEntry) -> String {
let Some(ref g) = node.geometry else {
return String::new();
};
if g.x.is_some() || g.y.is_some() || g.w.is_some() || g.h.is_some() {
let x = g.x.unwrap_or(0.0);
let y = g.y.unwrap_or(0.0);
let w = g.w.unwrap_or(0.0);
let h = g.h.unwrap_or(0.0);
return format!(
"{},{} {}x{}",
fmt_f64(x),
fmt_f64(y),
fmt_f64(w),
fmt_f64(h)
);
}
if g.x1.is_some() || g.y1.is_some() || g.x2.is_some() || g.y2.is_some() {
let x1 = g.x1.unwrap_or(0.0);
let y1 = g.y1.unwrap_or(0.0);
let x2 = g.x2.unwrap_or(0.0);
let y2 = g.y2.unwrap_or(0.0);
return format!(
"({},{})→({},{})",
fmt_f64(x1),
fmt_f64(y1),
fmt_f64(x2),
fmt_f64(y2)
);
}
if let Some(count) = g.point_count {
return format!("{} pts", count);
}
String::new()
}
fn render_flags(node: &NodeEntry) -> String {
let mut flags = Vec::new();
if node.visible == Some(false) {
flags.push("[hidden]");
}
if node.locked == Some(true) {
flags.push("[locked]");
}
flags.join(" ")
}
fn fmt_f64(v: f64) -> String {
if v.fract() == 0.0 {
(v as i64).to_string()
} else {
v.to_string()
}
}
#[cfg(test)]
#[path = "document_tests.rs"]
mod tests;