1use std::collections::BTreeMap;
12
13use zenith_core::{
14 Dimension, FrameNode, GroupNode, KdlAdapter, KdlSource, Node, Page, PropertyValue,
15 ResolvedToken, ResolvedValue, Unit, resolve_tokens,
16};
17
18use crate::commands::serialize_pretty;
19use crate::json_types::RecipeInspectJson;
20
21use super::recipes;
22
23#[derive(Debug)]
27pub struct InspectCmdErr {
28 pub message: String,
30 pub exit_code: u8,
32}
33
34impl InspectCmdErr {
35 fn new(msg: impl Into<String>, exit_code: u8) -> Self {
36 Self {
37 message: msg.into(),
38 exit_code,
39 }
40 }
41}
42
43#[derive(Debug, Clone, serde::Serialize)]
48pub struct NodeGeometry {
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub x: Option<f64>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub y: Option<f64>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub w: Option<f64>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub h: Option<f64>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub x1: Option<f64>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub y1: Option<f64>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub x2: Option<f64>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub y2: Option<f64>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub point_count: Option<usize>,
76}
77
78#[derive(Debug, Clone, serde::Serialize)]
80pub struct NodeEntry {
81 pub id: String,
82 pub kind: String,
83 #[serde(skip_serializing_if = "Option::is_none")]
87 pub role: Option<String>,
88 pub geometry: Option<NodeGeometry>,
89 pub visible: Option<bool>,
90 pub locked: Option<bool>,
91 pub children: Vec<NodeEntry>,
92}
93
94type Resolved = BTreeMap<String, ResolvedToken>;
97
98#[derive(Debug, Clone, serde::Serialize)]
100pub struct PageEntry {
101 pub id: String,
102 pub name: Option<String>,
103 pub width: f64,
104 pub height: f64,
105 pub children: Vec<NodeEntry>,
106}
107
108#[derive(Debug, serde::Serialize)]
110pub struct InspectOutput {
111 pub schema: &'static str,
112 pub pages: Vec<PageEntry>,
113 pub recipes: Vec<RecipeInspectJson>,
115}
116
117#[derive(Debug, serde::Serialize)]
119pub struct InspectNodeOutput {
120 pub schema: &'static str,
121 pub node: NodeEntry,
122}
123
124pub fn run(src: &str, node_id: Option<&str>, json: bool) -> Result<String, InspectCmdErr> {
135 let doc = KdlAdapter
137 .parse(src.as_bytes())
138 .map_err(|e| InspectCmdErr::new(format!("error[parse.error]: {}", e.message), 2))?;
139
140 let resolved = resolve_tokens(&doc.tokens).resolved;
141
142 if let Some(id) = node_id {
143 let entry = find_node_tree(&doc.body.pages, id, &resolved)
145 .ok_or_else(|| InspectCmdErr::new(format!("error: node '{}' not found", id), 2))?;
146
147 let out = if json {
148 let output = InspectNodeOutput {
149 schema: "zenith-inspect-v1",
150 node: entry,
151 };
152 serialize_pretty(&output)
153 } else {
154 render_node_human(&entry, 0).trim_end().to_owned()
155 };
156 Ok(out)
157 } else {
158 let pages = build_doc_tree(&doc.body.pages, &resolved);
160
161 let out = if json {
162 let recipe_entries = recipes::build_recipe_entries(&doc.recipes);
163 let output = InspectOutput {
164 schema: "zenith-inspect-v1",
165 pages,
166 recipes: recipe_entries,
167 };
168 serialize_pretty(&output)
169 } else {
170 let mut text = render_pages_human(&pages);
171 let recipe_section = recipes::render_recipes_human(&doc.recipes);
172 if !recipe_section.is_empty() {
173 text.push('\n');
174 text.push('\n');
175 text.push_str(&recipe_section);
176 }
177 text
178 };
179 Ok(out)
180 }
181}
182
183pub fn summary(
198 src: &str,
199 node: Option<&str>,
200 depth: usize,
201 detail: bool,
202) -> Result<serde_json::Value, InspectCmdErr> {
203 let doc = KdlAdapter
204 .parse(src.as_bytes())
205 .map_err(|e| InspectCmdErr::new(format!("error[parse.error]: {}", e.message), 2))?;
206
207 let resolved = resolve_tokens(&doc.tokens).resolved;
208
209 if let Some(id) = node {
210 let entry = find_node_tree(&doc.body.pages, id, &resolved)
211 .ok_or_else(|| InspectCmdErr::new(format!("error: node '{id}' not found"), 2))?;
212 Ok(serde_json::json!({
213 "schema": "zenith-inspect-summary-v1",
214 "node": trim_node(&entry, depth, detail),
215 }))
216 } else {
217 let pages = build_doc_tree(&doc.body.pages, &resolved);
218 let page_values: Vec<serde_json::Value> =
219 pages.iter().map(|p| trim_page(p, depth, detail)).collect();
220 Ok(serde_json::json!({
221 "schema": "zenith-inspect-summary-v1",
222 "pages": page_values,
223 "recipe_count": doc.recipes.len(),
224 }))
225 }
226}
227
228fn trim_page(p: &PageEntry, depth: usize, detail: bool) -> serde_json::Value {
230 let mut obj = serde_json::Map::new();
231 obj.insert("id".into(), p.id.clone().into());
232 if let Some(name) = &p.name {
233 obj.insert("name".into(), name.clone().into());
234 }
235 obj.insert("width".into(), p.width.into());
236 obj.insert("height".into(), p.height.into());
237 insert_children(&mut obj, &p.children, depth, detail);
238 serde_json::Value::Object(obj)
239}
240
241fn trim_node(n: &NodeEntry, depth: usize, detail: bool) -> serde_json::Value {
243 let mut obj = serde_json::Map::new();
244 obj.insert("id".into(), n.id.clone().into());
245 obj.insert("kind".into(), n.kind.clone().into());
246 if detail {
247 if let Some(role) = &n.role {
248 obj.insert("role".into(), role.clone().into());
249 }
250 if let Some(g) = &n.geometry {
251 obj.insert(
252 "geometry".into(),
253 serde_json::to_value(g).unwrap_or(serde_json::Value::Null),
254 );
255 }
256 if let Some(v) = n.visible {
257 obj.insert("visible".into(), v.into());
258 }
259 if let Some(l) = n.locked {
260 obj.insert("locked".into(), l.into());
261 }
262 }
263 insert_children(&mut obj, &n.children, depth, detail);
264 serde_json::Value::Object(obj)
265}
266
267fn insert_children(
270 obj: &mut serde_json::Map<String, serde_json::Value>,
271 children: &[NodeEntry],
272 depth: usize,
273 detail: bool,
274) {
275 if children.is_empty() {
276 return;
277 }
278 if depth == 0 {
279 obj.insert("child_count".into(), children.len().into());
280 } else {
281 let kids: Vec<serde_json::Value> = children
282 .iter()
283 .map(|c| trim_node(c, depth - 1, detail))
284 .collect();
285 obj.insert("children".into(), serde_json::Value::Array(kids));
286 }
287}
288
289pub fn build_doc_tree(pages: &[Page], resolved: &Resolved) -> Vec<PageEntry> {
296 pages
297 .iter()
298 .map(|p| build_page_entry(p, resolved))
299 .collect()
300}
301
302fn build_page_entry(page: &Page, resolved: &Resolved) -> PageEntry {
303 PageEntry {
304 id: page.id.clone(),
305 name: page.name.clone(),
306 width: dim_to_f64(&page.width),
307 height: dim_to_f64(&page.height),
308 children: page
309 .children
310 .iter()
311 .map(|n| build_node_entry(n, resolved))
312 .collect(),
313 }
314}
315
316fn build_node_entry(node: &Node, resolved: &Resolved) -> NodeEntry {
317 match node {
318 Node::Rect(n) => NodeEntry {
319 id: n.id.clone(),
320 kind: "rect".into(),
321 role: n.role.clone(),
322 geometry: bbox_geom(
323 n.x.as_ref(),
324 n.y.as_ref(),
325 n.w.as_ref(),
326 n.h.as_ref(),
327 resolved,
328 ),
329 visible: n.visible,
330 locked: n.locked,
331 children: vec![],
332 },
333 Node::Ellipse(n) => NodeEntry {
334 id: n.id.clone(),
335 kind: "ellipse".into(),
336 role: n.role.clone(),
337 geometry: bbox_geom(
338 n.x.as_ref(),
339 n.y.as_ref(),
340 n.w.as_ref(),
341 n.h.as_ref(),
342 resolved,
343 ),
344 visible: n.visible,
345 locked: n.locked,
346 children: vec![],
347 },
348 Node::Line(n) => NodeEntry {
349 id: n.id.clone(),
350 kind: "line".into(),
351 role: n.role.clone(),
352 geometry: Some(NodeGeometry {
353 x: None,
354 y: None,
355 w: None,
356 h: None,
357 x1: n.x1.as_ref().map(dim_to_f64),
358 y1: n.y1.as_ref().map(dim_to_f64),
359 x2: n.x2.as_ref().map(dim_to_f64),
360 y2: n.y2.as_ref().map(dim_to_f64),
361 point_count: None,
362 }),
363 visible: n.visible,
364 locked: n.locked,
365 children: vec![],
366 },
367 Node::Text(n) => NodeEntry {
368 id: n.id.clone(),
369 kind: "text".into(),
370 role: n.role.clone(),
371 geometry: bbox_geom(
372 n.x.as_ref(),
373 n.y.as_ref(),
374 n.w.as_ref(),
375 n.h.as_ref(),
376 resolved,
377 ),
378 visible: n.visible,
379 locked: n.locked,
380 children: vec![],
381 },
382 Node::Code(n) => NodeEntry {
383 id: n.id.clone(),
384 kind: "code".into(),
385 role: n.role.clone(),
386 geometry: bbox_geom(
387 n.x.as_ref(),
388 n.y.as_ref(),
389 n.w.as_ref(),
390 n.h.as_ref(),
391 resolved,
392 ),
393 visible: n.visible,
394 locked: n.locked,
395 children: vec![],
396 },
397 Node::Image(n) => NodeEntry {
398 id: n.id.clone(),
399 kind: "image".into(),
400 role: n.role.clone(),
401 geometry: bbox_geom(
402 n.x.as_ref(),
403 n.y.as_ref(),
404 n.w.as_ref(),
405 n.h.as_ref(),
406 resolved,
407 ),
408 visible: n.visible,
409 locked: n.locked,
410 children: vec![],
411 },
412 Node::Frame(n) => NodeEntry {
413 id: n.id.clone(),
414 kind: "frame".into(),
415 role: n.role.clone(),
416 geometry: bbox_geom(
417 n.x.as_ref(),
418 n.y.as_ref(),
419 n.w.as_ref(),
420 n.h.as_ref(),
421 resolved,
422 ),
423 visible: n.visible,
424 locked: n.locked,
425 children: n
426 .children
427 .iter()
428 .map(|c| build_node_entry(c, resolved))
429 .collect(),
430 },
431 Node::Group(n) => NodeEntry {
432 id: n.id.clone(),
433 kind: "group".into(),
434 role: n.role.clone(),
435 geometry: bbox_geom(
436 n.x.as_ref(),
437 n.y.as_ref(),
438 n.w.as_ref(),
439 n.h.as_ref(),
440 resolved,
441 ),
442 visible: n.visible,
443 locked: n.locked,
444 children: n
445 .children
446 .iter()
447 .map(|c| build_node_entry(c, resolved))
448 .collect(),
449 },
450 Node::Polygon(n) => NodeEntry {
451 id: n.id.clone(),
452 kind: "polygon".into(),
453 role: n.role.clone(),
454 geometry: Some(NodeGeometry {
455 x: None,
456 y: None,
457 w: None,
458 h: None,
459 x1: None,
460 y1: None,
461 x2: None,
462 y2: None,
463 point_count: Some(n.points.len()),
464 }),
465 visible: n.visible,
466 locked: n.locked,
467 children: vec![],
468 },
469 Node::Polyline(n) => NodeEntry {
470 id: n.id.clone(),
471 kind: "polyline".into(),
472 role: n.role.clone(),
473 geometry: Some(NodeGeometry {
474 x: None,
475 y: None,
476 w: None,
477 h: None,
478 x1: None,
479 y1: None,
480 x2: None,
481 y2: None,
482 point_count: Some(n.points.len()),
483 }),
484 visible: n.visible,
485 locked: n.locked,
486 children: vec![],
487 },
488 Node::Instance(n) => NodeEntry {
489 id: n.id.clone(),
490 kind: "instance".into(),
491 role: n.role.clone(),
492 geometry: Some(NodeGeometry {
495 x: opt_dim_to_f64(n.x.as_ref()),
496 y: opt_dim_to_f64(n.y.as_ref()),
497 w: None,
498 h: None,
499 x1: None,
500 y1: None,
501 x2: None,
502 y2: None,
503 point_count: None,
504 }),
505 visible: n.visible,
506 locked: n.locked,
507 children: vec![],
508 },
509 Node::Field(n) => NodeEntry {
510 id: n.id.clone(),
511 kind: "field".into(),
512 role: n.role.clone(),
513 geometry: bbox_geom(
516 n.x.as_ref(),
517 n.y.as_ref(),
518 n.w.as_ref(),
519 n.h.as_ref(),
520 resolved,
521 ),
522 visible: n.visible,
523 locked: n.locked,
524 children: vec![],
525 },
526 Node::Toc(n) => NodeEntry {
527 id: n.id.clone(),
528 kind: "toc".into(),
529 role: n.role.clone(),
530 geometry: bbox_geom(
533 n.x.as_ref(),
534 n.y.as_ref(),
535 n.w.as_ref(),
536 n.h.as_ref(),
537 resolved,
538 ),
539 visible: n.visible,
540 locked: n.locked,
541 children: vec![],
542 },
543 Node::Footnote(n) => NodeEntry {
544 id: n.id.clone(),
545 kind: "footnote".into(),
546 role: n.role.clone(),
547 geometry: None,
550 visible: None,
551 locked: None,
552 children: vec![],
553 },
554 Node::Table(n) => NodeEntry {
555 id: n.id.clone(),
556 kind: "table".into(),
557 role: n.role.clone(),
558 geometry: bbox_geom(
559 n.x.as_ref(),
560 n.y.as_ref(),
561 n.w.as_ref(),
562 n.h.as_ref(),
563 resolved,
564 ),
565 visible: n.visible,
566 locked: n.locked,
567 children: n
570 .rows
571 .iter()
572 .flat_map(|row| row.cells.iter())
573 .flat_map(|cell| cell.children.iter())
574 .map(|c| build_node_entry(c, resolved))
575 .collect(),
576 },
577 Node::Shape(n) => NodeEntry {
578 id: n.id.clone(),
579 kind: "shape".into(),
580 role: n.role.clone(),
581 geometry: bbox_geom(
582 n.x.as_ref(),
583 n.y.as_ref(),
584 n.w.as_ref(),
585 n.h.as_ref(),
586 resolved,
587 ),
588 visible: n.visible,
589 locked: n.locked,
590 children: vec![],
593 },
594 Node::Connector(n) => NodeEntry {
595 id: n.id.clone(),
596 kind: "connector".into(),
597 role: n.role.clone(),
598 geometry: None,
601 visible: n.visible,
602 locked: n.locked,
603 children: vec![],
604 },
605 Node::Pattern(n) => NodeEntry {
606 id: n.id.clone(),
607 kind: "pattern".into(),
608 role: n.role.clone(),
609 geometry: bbox_geom(
610 n.x.as_ref(),
611 n.y.as_ref(),
612 n.w.as_ref(),
613 n.h.as_ref(),
614 resolved,
615 ),
616 visible: n.visible,
617 locked: n.locked,
618 children: vec![],
619 },
620 Node::Chart(n) => NodeEntry {
621 id: n.id.clone(),
622 kind: "chart".into(),
623 role: n.role.clone(),
624 geometry: bbox_geom(
625 n.x.as_ref(),
626 n.y.as_ref(),
627 n.w.as_ref(),
628 n.h.as_ref(),
629 resolved,
630 ),
631 visible: n.visible,
632 locked: n.locked,
633 children: vec![],
634 },
635 Node::Light(n) => NodeEntry {
636 id: n.id.clone(),
637 kind: "light".into(),
638 role: n.role.clone(),
639 geometry: light_geom(n, resolved),
640 visible: n.visible,
641 locked: n.locked,
642 children: vec![],
643 },
644 Node::Mesh(n) => NodeEntry {
645 id: n.id.clone(),
646 kind: "mesh".into(),
647 role: n.role.clone(),
648 geometry: bbox_geom(
649 n.x.as_ref(),
650 n.y.as_ref(),
651 n.w.as_ref(),
652 n.h.as_ref(),
653 resolved,
654 ),
655 visible: n.visible,
656 locked: n.locked,
657 children: vec![],
658 },
659 Node::Unknown(n) => NodeEntry {
660 id: n.id.clone().unwrap_or_default(),
661 kind: n.kind.clone(),
662 role: None,
664 geometry: None,
665 visible: None,
666 locked: None,
667 children: n
668 .children
669 .iter()
670 .map(|c| build_node_entry(c, resolved))
671 .collect(),
672 },
673 }
674}
675
676pub fn find_node_tree(pages: &[Page], id: &str, resolved: &Resolved) -> Option<NodeEntry> {
681 for page in pages {
682 if let Some(entry) = search_nodes(&page.children, id, resolved) {
683 return Some(entry);
684 }
685 }
686 None
687}
688
689fn search_nodes(nodes: &[Node], id: &str, resolved: &Resolved) -> Option<NodeEntry> {
690 for node in nodes {
691 let node_id = node_id_str(node);
693 if node_id == id {
694 return Some(build_node_entry(node, resolved));
695 }
696 if let Some(children) = node_children(node)
698 && let Some(found) = search_nodes(children, id, resolved)
699 {
700 return Some(found);
701 }
702 if let Node::Table(t) = node {
704 for row in &t.rows {
705 for cell in &row.cells {
706 if let Some(found) = search_nodes(&cell.children, id, resolved) {
707 return Some(found);
708 }
709 }
710 }
711 }
712 }
713 None
714}
715
716fn node_id_str(node: &Node) -> &str {
718 match node {
719 Node::Rect(n) => &n.id,
720 Node::Ellipse(n) => &n.id,
721 Node::Line(n) => &n.id,
722 Node::Text(n) => &n.id,
723 Node::Code(n) => &n.id,
724 Node::Frame(n) => &n.id,
725 Node::Group(n) => &n.id,
726 Node::Image(n) => &n.id,
727 Node::Polygon(n) => &n.id,
728 Node::Polyline(n) => &n.id,
729 Node::Instance(n) => &n.id,
730 Node::Field(n) => &n.id,
731 Node::Toc(n) => &n.id,
732 Node::Footnote(n) => &n.id,
733 Node::Table(n) => &n.id,
734 Node::Shape(n) => &n.id,
735 Node::Connector(n) => &n.id,
736 Node::Pattern(n) => &n.id,
737 Node::Chart(n) => &n.id,
738 Node::Light(n) => &n.id,
739 Node::Mesh(n) => &n.id,
740 Node::Unknown(n) => n.id.as_deref().unwrap_or(""),
741 }
742}
743
744fn node_children(node: &Node) -> Option<&[Node]> {
747 match node {
748 Node::Frame(FrameNode { children, .. }) | Node::Group(GroupNode { children, .. }) => {
749 Some(children)
750 }
751 Node::Unknown(n) => Some(&n.children),
752 Node::Rect(_)
753 | Node::Ellipse(_)
754 | Node::Line(_)
755 | Node::Text(_)
756 | Node::Code(_)
757 | Node::Image(_)
758 | Node::Polygon(_)
759 | Node::Polyline(_)
760 | Node::Instance(_)
761 | Node::Field(_)
762 | Node::Footnote(_)
763 | Node::Toc(_)
764 | Node::Table(_)
765 | Node::Shape(_)
766 | Node::Connector(_)
767 | Node::Pattern(_)
768 | Node::Chart(_)
769 | Node::Light(_)
770 | Node::Mesh(_) => None,
771 }
772}
773
774fn dim_to_f64(d: &Dimension) -> f64 {
777 match d.unit {
778 Unit::Pt => d.value * 96.0 / 72.0,
779 Unit::Px | Unit::Pct | Unit::Deg | Unit::Unknown(_) => d.value,
780 }
781}
782
783fn opt_dim_to_f64(d: Option<&Dimension>) -> Option<f64> {
784 d.map(dim_to_f64)
785}
786
787fn opt_pv_to_f64(pv: Option<&PropertyValue>, resolved: &Resolved) -> Option<f64> {
793 match pv? {
794 PropertyValue::Dimension(d) => Some(dim_to_f64(d)),
795 PropertyValue::TokenRef(id) => match resolved.get(id).map(|t| &t.value) {
796 Some(ResolvedValue::Dimension(d)) => Some(dim_to_f64(d)),
797 _ => None,
798 },
799 PropertyValue::Literal(_) | PropertyValue::DataRef(_) => None,
800 }
801}
802
803fn bbox_geom(
804 x: Option<&PropertyValue>,
805 y: Option<&PropertyValue>,
806 w: Option<&PropertyValue>,
807 h: Option<&PropertyValue>,
808 resolved: &Resolved,
809) -> Option<NodeGeometry> {
810 Some(NodeGeometry {
811 x: opt_pv_to_f64(x, resolved),
812 y: opt_pv_to_f64(y, resolved),
813 w: opt_pv_to_f64(w, resolved),
814 h: opt_pv_to_f64(h, resolved),
815 x1: None,
816 y1: None,
817 x2: None,
818 y2: None,
819 point_count: None,
820 })
821}
822
823fn light_geom(n: &zenith_core::LightNode, resolved: &Resolved) -> Option<NodeGeometry> {
824 let x = opt_pv_to_f64(n.x.as_ref(), resolved)?;
825 let y = opt_pv_to_f64(n.y.as_ref(), resolved)?;
826 let radius = opt_pv_to_f64(n.radius.as_ref(), resolved)?;
827 Some(NodeGeometry {
828 x: Some(x - radius),
829 y: Some(y - radius),
830 w: Some(radius * 2.0),
831 h: Some(radius * 2.0),
832 x1: None,
833 y1: None,
834 x2: None,
835 y2: None,
836 point_count: None,
837 })
838}
839
840fn render_pages_human(pages: &[PageEntry]) -> String {
843 let mut out = String::new();
844 for page in pages {
845 let name_part = page
846 .name
847 .as_deref()
848 .map(|n| format!(" \"{}\"", n))
849 .unwrap_or_default();
850 out.push_str(&format!(
851 "page {}{} ({}x{})\n",
852 page.id, name_part, page.width, page.height
853 ));
854 for child in &page.children {
855 out.push_str(&render_node_human(child, 1));
856 }
857 }
858 out.trim_end().to_owned()
859}
860
861fn render_node_human(node: &NodeEntry, depth: usize) -> String {
864 let indent = " ".repeat(depth);
865 let geom = render_geom_summary(node);
866 let flags = render_flags(node);
867 let suffix = [geom, flags]
868 .into_iter()
869 .filter(|s| !s.is_empty())
870 .collect::<Vec<_>>()
871 .join(" ");
872 let suffix_part = if suffix.is_empty() {
873 String::new()
874 } else {
875 format!(" {}", suffix)
876 };
877
878 let mut out = format!("{}{} {}{}\n", indent, node.kind, node.id, suffix_part);
879 for child in &node.children {
880 out.push_str(&render_node_human(child, depth + 1));
881 }
882 out
883}
884
885fn render_geom_summary(node: &NodeEntry) -> String {
886 let Some(ref g) = node.geometry else {
887 return String::new();
888 };
889
890 if g.x.is_some() || g.y.is_some() || g.w.is_some() || g.h.is_some() {
892 let x = g.x.unwrap_or(0.0);
893 let y = g.y.unwrap_or(0.0);
894 let w = g.w.unwrap_or(0.0);
895 let h = g.h.unwrap_or(0.0);
896 return format!(
897 "{},{} {}x{}",
898 fmt_f64(x),
899 fmt_f64(y),
900 fmt_f64(w),
901 fmt_f64(h)
902 );
903 }
904
905 if g.x1.is_some() || g.y1.is_some() || g.x2.is_some() || g.y2.is_some() {
907 let x1 = g.x1.unwrap_or(0.0);
908 let y1 = g.y1.unwrap_or(0.0);
909 let x2 = g.x2.unwrap_or(0.0);
910 let y2 = g.y2.unwrap_or(0.0);
911 return format!(
912 "({},{})→({},{})",
913 fmt_f64(x1),
914 fmt_f64(y1),
915 fmt_f64(x2),
916 fmt_f64(y2)
917 );
918 }
919
920 if let Some(count) = g.point_count {
922 return format!("{} pts", count);
923 }
924
925 String::new()
926}
927
928fn render_flags(node: &NodeEntry) -> String {
929 let mut flags = Vec::new();
930 if node.visible == Some(false) {
931 flags.push("[hidden]");
932 }
933 if node.locked == Some(true) {
934 flags.push("[locked]");
935 }
936 flags.join(" ")
937}
938
939fn fmt_f64(v: f64) -> String {
941 if v.fract() == 0.0 {
942 (v as i64).to_string()
943 } else {
944 v.to_string()
945 }
946}
947
948#[cfg(test)]
951#[path = "document_tests.rs"]
952mod tests;