1use std::collections::HashMap;
8
9use serde_json::{json, Value};
10
11use crate::arrows::{
12 edge_anchors, edge_primitives as paint_edge, simplify_route, EdgePaintIn, EdgePrim, ANCHOR_GAP,
13 KINK_PX,
14};
15use crate::scene::{Primitive, Scene, TextAnchor, Viewport};
16
17use super::{
18 DepictSpec, DocPaint, EdgeNode, EdgeRouting, LabelPos, LayoutOpts, Node,
19};
20
21const EDGE_LABEL_FONT_PX: f64 = 11.0;
22const EDGE_LABEL_PAD: f64 = 4.0;
23const ROW_GAP: f64 = 0.0; pub fn compose_scheme(spec: &DepictSpec, paints: &[DocPaint]) -> Result<Scene, String> {
29 match spec {
30 DepictSpec::ReactionScheme { layout, .. } => {
31 compose_reaction(spec, paints, layout.as_ref().unwrap_or(&LayoutOpts::default()))
32 }
33 DepictSpec::Mol { .. } | DepictSpec::Group { .. } => {
34 if paints.len() == 1 {
35 Ok(paints[0].scene.clone())
36 } else if paints.is_empty() {
37 Ok(Scene {
38 width: 0.0,
39 height: 0.0,
40 viewports: vec![],
41 overlays: vec![],
42 halo: vec![],
43 })
44 } else {
45 Ok(place_row(paints, 16.0))
47 }
48 }
49 }
50}
51
52fn compose_reaction(
53 spec: &DepictSpec,
54 paints: &[DocPaint],
55 layout: &LayoutOpts,
56) -> Result<Scene, String> {
57 if paints.is_empty() {
58 return Ok(Scene {
59 width: 0.0,
60 height: 0.0,
61 viewports: vec![],
62 overlays: vec![],
63 halo: vec![],
64 });
65 }
66
67 let by_id: HashMap<&str, &DocPaint> = paints.iter().map(|p| (p.id.as_str(), p)).collect();
68 let _ = by_id;
69 let sizes: Vec<(String, f64, f64)> = paints
70 .iter()
71 .map(|p| {
72 let (w, h) = scene_size(&p.scene);
73 (p.id.clone(), w, h)
74 })
75 .collect();
76
77 let placement = match layout_scheme(spec, layout, &sizes) {
78 Ok(p) => p,
79 Err(_) => row_placement(&sizes),
80 };
81
82 let mut viewports = Vec::with_capacity(paints.len());
83 let mut halo = Vec::new();
84 let mut max_r = 0.0_f64;
85 let mut max_b = 0.0_f64;
86
87 for p in paints {
88 let (x, y) = placement
89 .positions
90 .get(p.id.as_str())
91 .copied()
92 .unwrap_or((0.0, 0.0));
93 let (w, h) = scene_size(&p.scene);
94 let mut vp = p
95 .scene
96 .viewports
97 .first()
98 .cloned()
99 .unwrap_or(Viewport {
100 id: Some(p.id.clone()),
101 x: 0.0,
102 y: 0.0,
103 width: w,
104 height: h,
105 layers: vec![],
106 });
107 vp.id = Some(p.id.clone());
108 vp.x = x;
109 vp.y = y;
110 vp.width = w;
111 vp.height = h;
112 max_r = max_r.max(x + w);
113 max_b = max_b.max(y + h);
114 for prim in &p.scene.halo {
115 halo.push(translate_prim(prim, x, y));
116 }
117 viewports.push(vp);
118 }
119
120 let vp_boxes: HashMap<&str, (f64, f64, f64, f64)> = viewports
121 .iter()
122 .filter_map(|vp| {
123 let id = vp.id.as_deref()?;
124 Some((id, (vp.x, vp.y, vp.width, vp.height)))
125 })
126 .collect();
127
128 let mut overlays = Vec::new();
129 for (i, edge) in spec.edges().iter().enumerate() {
130 let src_id = edge
131 .sources
132 .as_slice()
133 .first()
134 .map(String::as_str)
135 .unwrap_or("");
136 let tgt_id = edge
137 .targets
138 .as_slice()
139 .first()
140 .map(String::as_str)
141 .unwrap_or("");
142 let Some(&src_box) = vp_boxes.get(src_id) else {
143 continue;
144 };
145 let Some(&tgt_box) = vp_boxes.get(tgt_id) else {
146 continue;
147 };
148
149 let route = placement
150 .edge_paths
151 .get(i)
152 .and_then(|p| p.as_ref())
153 .cloned()
154 .filter(|pts| pts.len() >= 2)
155 .unwrap_or_else(|| {
156 let (a, b) = edge_anchors(src_box, tgt_box, ANCHOR_GAP);
157 vec![a, b]
158 });
159 let pts = simplify_route(&route, KINK_PX);
160 for &(x, y) in &pts {
161 max_r = max_r.max(x + 8.0);
162 max_b = max_b.max(y + 8.0);
163 }
164
165 let (label_text, label_pos) = resolve_edge_label(spec, edge);
166 let paint = EdgePaintIn {
167 pts,
168 arrow: edge.arrow,
169 routing: Some(edge.edge_routing_or(layout)),
170 color: edge.color.clone(),
171 stroke_width: edge.stroke_width,
172 dashed: edge.dashed,
173 label: label_text,
174 label_pos,
175 index: i,
176 };
177 for prim in paint_edge(&paint) {
178 let scene_prim = edge_prim_to_scene(&prim);
179 expand_bounds_prim(&scene_prim, &mut max_r, &mut max_b);
180 overlays.push(scene_prim);
181 }
182 }
183
184 Ok(Scene {
185 width: max_r.max(placement.width.unwrap_or(0.0)),
186 height: max_b.max(placement.height.unwrap_or(0.0)),
187 viewports,
188 overlays,
189 halo,
190 })
191}
192
193fn scene_size(scene: &Scene) -> (f64, f64) {
194 if let Some(vp) = scene.viewports.first() {
195 (vp.width.max(scene.width), vp.height.max(scene.height))
196 } else {
197 (scene.width, scene.height)
198 }
199}
200
201fn place_row(paints: &[DocPaint], gap: f64) -> Scene {
202 let sizes: Vec<(String, f64, f64)> = paints
203 .iter()
204 .map(|p| {
205 let (w, h) = scene_size(&p.scene);
206 (p.id.clone(), w, h)
207 })
208 .collect();
209 let max_h = sizes.iter().map(|(_, _, h)| *h).fold(0.0_f64, f64::max);
210 let mut viewports = Vec::new();
211 let mut halo = Vec::new();
212 let mut max_r = 0.0_f64;
213 let mut max_b = 0.0_f64;
214 let mut x = 0.0_f64;
215 for p in paints {
216 let (w, h) = scene_size(&p.scene);
217 let y = (max_h - h) * 0.5;
218 let mut vp = p.scene.viewports.first().cloned().unwrap_or(Viewport {
219 id: Some(p.id.clone()),
220 x: 0.0,
221 y: 0.0,
222 width: w,
223 height: h,
224 layers: vec![],
225 });
226 vp.id = Some(p.id.clone());
227 vp.x = x;
228 vp.y = y;
229 max_r = max_r.max(x + w);
230 max_b = max_b.max(y + h);
231 for prim in &p.scene.halo {
232 halo.push(translate_prim(prim, x, y));
233 }
234 viewports.push(vp);
235 x += w + gap;
236 }
237 Scene {
238 width: max_r,
239 height: max_b,
240 viewports,
241 overlays: vec![],
242 halo,
243 }
244}
245
246struct Placement {
247 positions: HashMap<String, (f64, f64)>,
248 edge_paths: Vec<Option<Vec<(f64, f64)>>>,
249 width: Option<f64>,
250 height: Option<f64>,
251}
252
253fn row_placement(sizes: &[(String, f64, f64)]) -> Placement {
254 let max_h = sizes.iter().map(|(_, _, h)| *h).fold(0.0_f64, f64::max);
255 let mut positions = HashMap::new();
256 let mut x = 0.0;
257 for (id, w, h) in sizes {
258 let y = (max_h - h) * 0.5;
259 positions.insert(id.clone(), (x, y));
260 x += w + ROW_GAP;
261 }
262 Placement {
263 positions,
264 edge_paths: vec![],
265 width: Some(x),
266 height: Some(max_h),
267 }
268}
269
270#[cfg(feature = "elk")]
271fn layout_scheme(
272 spec: &DepictSpec,
273 layout: &LayoutOpts,
274 sizes: &[(String, f64, f64)],
275) -> Result<Placement, String> {
276 let graph = build_elk_graph(spec, layout, sizes);
277 let laid = crate::elk::layout_json(&serde_json::to_string(&graph).map_err(|e| e.to_string())?)?;
278 parse_elk_placement(spec, &laid, sizes)
279}
280
281#[cfg(not(feature = "elk"))]
282fn layout_scheme(
283 _spec: &DepictSpec,
284 _layout: &LayoutOpts,
285 sizes: &[(String, f64, f64)],
286) -> Result<Placement, String> {
287 Ok(row_placement(sizes))
288}
289
290#[cfg(feature = "elk")]
291fn build_elk_graph(
292 spec: &DepictSpec,
293 layout: &LayoutOpts,
294 sizes: &[(String, f64, f64)],
295) -> Value {
296 let opts = crate::elk::scheme_layout_options(crate::elk::DiagramKind::Reaction, layout);
297 let children: Vec<Value> = sizes
298 .iter()
299 .map(|(id, w, h)| {
300 json!({
301 "id": id,
302 "width": w,
303 "height": h,
304 })
305 })
306 .collect();
307
308 let mut edges = Vec::new();
309 for (i, e) in spec.edges().iter().enumerate() {
310 let eid = format!("e{i}");
311 let mut entry = json!({
312 "id": eid,
313 "sources": e.sources.as_slice(),
314 "targets": e.targets.as_slice(),
315 });
316 if let Some(labels) = elk_edge_labels(spec, e, &format!("e{i}")) {
317 entry["labels"] = labels;
318 }
319 if let Some(r) = e.edge_routing {
320 entry["layoutOptions"] = json!({
321 "elk.edgeRouting": routing_name(r),
322 });
323 }
324 edges.push(entry);
325 }
326
327 json!({
328 "id": "root",
329 "layoutOptions": opts,
330 "children": children,
331 "edges": edges,
332 })
333}
334
335#[cfg(feature = "elk")]
336fn routing_name(r: EdgeRouting) -> &'static str {
337 match r {
338 EdgeRouting::Orthogonal => "ORTHOGONAL",
339 EdgeRouting::Polyline => "POLYLINE",
340 EdgeRouting::Splines => "SPLINES",
341 }
342}
343
344#[cfg(feature = "elk")]
345fn elk_edge_labels(spec: &DepictSpec, edge: &EdgeNode, edge_id: &str) -> Option<Value> {
346 let (text, pos) = resolve_edge_label(spec, edge);
347 let text = text?.trim().to_string();
348 if text.is_empty() {
349 return None;
350 }
351 let (w, h) = measure_label(&text);
352 let side = match pos.as_deref().unwrap_or("above") {
353 "below" | "right" => "ALWAYS_DOWN",
354 _ => "ALWAYS_UP",
355 };
356 Some(json!([{
357 "id": format!("{edge_id}_lab"),
358 "text": text,
359 "width": w,
360 "height": h,
361 "layoutOptions": {
362 "elk.edgeLabels.placement": "CENTER",
363 "elk.layered.edgeLabels.sideSelection": side,
364 }
365 }]))
366}
367
368fn measure_label(text: &str) -> (f64, f64) {
369 #[cfg(feature = "font")]
370 {
371 use crate::font::{face_metrics, glyph_metrics, FaceStyle};
372 let face = face_metrics(FaceStyle::Regular);
373 let scale = EDGE_LABEL_FONT_PX / face.upem.max(1.0);
374 let mut adv = 0.0;
375 let mut ink_w = 0.0;
376 for ch in text.chars() {
377 if let Some(g) = glyph_metrics(ch, FaceStyle::Regular) {
378 adv += g.advance * scale;
379 ink_w += g.ink_width() * scale;
380 } else {
381 adv += EDGE_LABEL_FONT_PX * 0.5;
382 }
383 }
384 let w = adv.max(ink_w) + EDGE_LABEL_PAD;
385 let typo = (face.ascent - face.descent).abs().max(face.upem * 0.8);
386 let h = typo * scale + EDGE_LABEL_PAD;
387 (w, h)
388 }
389 #[cfg(not(feature = "font"))]
390 {
391 let w = text.len() as f64 * EDGE_LABEL_FONT_PX * 0.55 + EDGE_LABEL_PAD;
392 let h = EDGE_LABEL_FONT_PX + EDGE_LABEL_PAD;
393 (w, h)
394 }
395}
396
397#[cfg(feature = "elk")]
398fn parse_elk_placement(
399 spec: &DepictSpec,
400 laid_json: &str,
401 sizes: &[(String, f64, f64)],
402) -> Result<Placement, String> {
403 let laid: Value = serde_json::from_str(laid_json).map_err(|e| e.to_string())?;
404 let mut positions = HashMap::new();
405 for (id, _, _) in sizes {
406 positions.insert(id.clone(), (0.0, 0.0));
407 }
408 if let Some(children) = laid.get("children").and_then(|c| c.as_array()) {
409 for c in children {
410 let Some(id) = c.get("id").and_then(|v| v.as_str()) else {
411 continue;
412 };
413 let x = c.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
414 let y = c.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
415 positions.insert(id.to_string(), (x, y));
416 }
417 }
418
419 let edges_out = laid.get("edges").and_then(|e| e.as_array());
420 let mut edge_paths = Vec::with_capacity(spec.edges().len());
421 for i in 0..spec.edges().len() {
422 let eid = format!("e{i}");
423 let path = edges_out.and_then(|arr| {
424 arr.iter()
425 .find(|e| e.get("id").and_then(|v| v.as_str()) == Some(eid.as_str()))
426 .and_then(edge_path_from_elk)
427 });
428 edge_paths.push(path);
429 }
430
431 Ok(Placement {
432 positions,
433 edge_paths,
434 width: laid.get("width").and_then(|v| v.as_f64()),
435 height: laid.get("height").and_then(|v| v.as_f64()),
436 })
437}
438
439#[cfg(feature = "elk")]
440fn edge_path_from_elk(edge: &Value) -> Option<Vec<(f64, f64)>> {
441 let sections = edge.get("sections")?.as_array()?;
442 if sections.is_empty() {
443 return None;
444 }
445 let mut path = Vec::new();
446 for sec in sections {
447 let pts = section_points(sec);
448 if pts.is_empty() {
449 continue;
450 }
451 if path.is_empty() {
452 path.extend(pts);
453 } else if pts[0] == *path.last().unwrap() {
454 path.extend(pts.into_iter().skip(1));
455 } else {
456 path.extend(pts);
457 }
458 }
459 if path.len() >= 2 {
460 Some(path)
461 } else {
462 None
463 }
464}
465
466#[cfg(feature = "elk")]
467fn section_points(section: &Value) -> Vec<(f64, f64)> {
468 let start = section.get("startPoint").unwrap_or(&Value::Null);
469 let end = section.get("endPoint").unwrap_or(&Value::Null);
470 let mut pts = vec![(
471 start.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0),
472 start.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0),
473 )];
474 if let Some(bends) = section.get("bendPoints").and_then(|b| b.as_array()) {
475 for bp in bends {
476 pts.push((
477 bp.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0),
478 bp.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0),
479 ));
480 }
481 }
482 pts.push((
483 end.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0),
484 end.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0),
485 ));
486 let mut cleaned = Vec::new();
487 for p in pts {
488 if cleaned
489 .last()
490 .map(|&(lx, ly): &(f64, f64)| (p.0 - lx).abs() > 1e-6 || (p.1 - ly).abs() > 1e-6)
491 .unwrap_or(true)
492 {
493 cleaned.push(p);
494 }
495 }
496 cleaned
497}
498
499fn resolve_edge_label(spec: &DepictSpec, edge: &EdgeNode) -> (Option<String>, Option<String>) {
500 let Some(ref lab) = edge.label else {
501 return (None, None);
502 };
503 let placements = lab.placements();
504 let Some((id, pos)) = placements.first() else {
505 return (None, None);
506 };
507 let text = match spec.node_by_id(id.trim()) {
508 Some(Node::Text(t)) => Some(t.text.clone()),
509 None if !id.trim().is_empty() && spec.node_by_id(id.trim()).is_none() => {
513 None
515 }
516 _ => None,
517 };
518 let pos = Some(match pos {
519 LabelPos::Above => "above".into(),
520 LabelPos::Below => "below".into(),
521 LabelPos::Left => "left".into(),
522 LabelPos::Right => "right".into(),
523 });
524 (text, pos)
525}
526
527fn edge_prim_to_scene(p: &EdgePrim) -> Primitive {
528 match p {
529 EdgePrim::Path {
530 d,
531 stroke,
532 fill,
533 stroke_width,
534 stroke_dasharray,
535 class,
536 } => Primitive::Path {
537 d: d.clone(),
538 stroke: if stroke == "none" {
539 None
540 } else {
541 Some(stroke.clone())
542 },
543 fill: if fill == "none" {
544 None
545 } else {
546 Some(fill.clone())
547 },
548 stroke_width: *stroke_width,
549 opacity: 1.0,
550 stroke_dasharray: stroke_dasharray.clone(),
551 stroke_linecap: None,
552 class: Some(class.clone()),
553 data_text: None,
554 },
555 EdgePrim::Text {
556 x,
557 y,
558 text,
559 fill,
560 font_size,
561 anchor,
562 class,
563 } => Primitive::Text {
564 x: *x,
565 y: *y,
566 text: text.clone(),
567 fill: fill.clone(),
568 font_size: *font_size,
569 anchor: match anchor.as_str() {
570 "start" => TextAnchor::Start,
571 "end" => TextAnchor::End,
572 _ => TextAnchor::Middle,
573 },
574 class: Some(class.clone()),
575 },
576 }
577}
578
579fn translate_prim(p: &Primitive, dx: f64, dy: f64) -> Primitive {
580 match p {
581 Primitive::Path {
582 d,
583 stroke,
584 fill,
585 stroke_width,
586 opacity,
587 stroke_dasharray,
588 stroke_linecap,
589 class,
590 data_text,
591 } => Primitive::Path {
592 d: shift_path_d(d, dx, dy),
593 stroke: stroke.clone(),
594 fill: fill.clone(),
595 stroke_width: *stroke_width,
596 opacity: *opacity,
597 stroke_dasharray: stroke_dasharray.clone(),
598 stroke_linecap: stroke_linecap.clone(),
599 class: class.clone(),
600 data_text: data_text.clone(),
601 },
602 Primitive::Circle {
603 cx,
604 cy,
605 r,
606 fill,
607 stroke,
608 stroke_width,
609 opacity,
610 class,
611 } => Primitive::Circle {
612 cx: cx + dx,
613 cy: cy + dy,
614 r: *r,
615 fill: fill.clone(),
616 stroke: stroke.clone(),
617 stroke_width: *stroke_width,
618 opacity: *opacity,
619 class: class.clone(),
620 },
621 Primitive::Text {
622 x,
623 y,
624 text,
625 fill,
626 font_size,
627 anchor,
628 class,
629 } => Primitive::Text {
630 x: x + dx,
631 y: y + dy,
632 text: text.clone(),
633 fill: fill.clone(),
634 font_size: *font_size,
635 anchor: *anchor,
636 class: class.clone(),
637 },
638 }
639}
640
641fn shift_path_d(d: &str, dx: f64, dy: f64) -> String {
642 let mut out = String::with_capacity(d.len() + 16);
644 let mut chars = d.chars().peekable();
645 let mut cmd = 'M';
646 while let Some(c) = chars.next() {
647 if c.is_ascii_alphabetic() {
648 cmd = c;
649 out.push(c);
650 continue;
651 }
652 if c.is_whitespace() || c == ',' {
653 out.push(c);
654 continue;
655 }
656 let mut num = String::new();
658 num.push(c);
659 while let Some(&n) = chars.peek() {
660 if n.is_ascii_digit() || n == '.' || n == 'e' || n == 'E' || n == '+' || n == '-' {
661 if (n == '-' || n == '+') && !num.is_empty() && !num.ends_with('e') && !num.ends_with('E')
663 {
664 break;
665 }
666 num.push(n);
667 chars.next();
668 } else {
669 break;
670 }
671 }
672 if matches!(cmd, 'M' | 'L' | 'm' | 'l') {
674 while matches!(chars.peek(), Some(ch) if ch.is_whitespace() || *ch == ',') {
676 chars.next();
677 }
678 let mut num2 = String::new();
679 if let Some(&n0) = chars.peek() {
680 if n0.is_ascii_digit() || n0 == '.' || n0 == '-' || n0 == '+' {
681 num2.push(n0);
682 chars.next();
683 while let Some(&n) = chars.peek() {
684 if n.is_ascii_digit() || n == '.' || n == 'e' || n == 'E' || n == '+' || n == '-'
685 {
686 if (n == '-' || n == '+')
687 && !num2.is_empty()
688 && !num2.ends_with('e')
689 && !num2.ends_with('E')
690 {
691 break;
692 }
693 num2.push(n);
694 chars.next();
695 } else {
696 break;
697 }
698 }
699 }
700 }
701 if let (Ok(x), Ok(y)) = (num.parse::<f64>(), num2.parse::<f64>()) {
702 if cmd == 'M' || cmd == 'L' {
703 out.push_str(&format!("{:.2} {:.2}", x + dx, y + dy));
704 } else {
705 out.push_str(&num);
707 out.push(' ');
708 out.push_str(&num2);
709 }
710 continue;
711 }
712 }
713 out.push_str(&num);
714 }
715 out
716}
717
718fn expand_bounds_prim(p: &Primitive, max_r: &mut f64, max_b: &mut f64) {
719 match p {
720 Primitive::Text { x, y, .. } => {
721 *max_r = max_r.max(*x + 12.0);
722 *max_b = max_b.max(*y + 12.0);
723 }
724 Primitive::Path { d, .. } => {
725 for (x, y) in path_coords(d) {
726 *max_r = max_r.max(x + 8.0);
727 *max_b = max_b.max(y + 8.0);
728 }
729 }
730 Primitive::Circle { cx, cy, r, .. } => {
731 *max_r = max_r.max(cx + r + 4.0);
732 *max_b = max_b.max(cy + r + 4.0);
733 }
734 }
735}
736
737fn path_coords(d: &str) -> Vec<(f64, f64)> {
738 let mut nums = Vec::new();
739 let mut cur = String::new();
740 for c in d.chars() {
741 if c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E' {
742 cur.push(c);
743 } else if !cur.is_empty() {
744 if let Ok(v) = cur.parse::<f64>() {
745 nums.push(v);
746 }
747 cur.clear();
748 }
749 }
750 if !cur.is_empty() {
751 if let Ok(v) = cur.parse::<f64>() {
752 nums.push(v);
753 }
754 }
755 let mut out = Vec::new();
756 let mut i = 0;
757 while i + 1 < nums.len() {
758 out.push((nums[i], nums[i + 1]));
759 i += 2;
760 }
761 out
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::arrows::EdgePrim;
768 use crate::doc::{EdgeArrow, EdgeNodeKind, MolIds, MolNode, MolNodeKind, TextNode, TextNodeKind};
769 use crate::scene::{AtomIn, Layer, LayerName, MoleculeIn, TextAnchor};
770
771 fn paint(id: &str, w: f64, h: f64) -> DocPaint {
772 DocPaint {
773 id: id.into(),
774 molecule: MoleculeIn {
775 id: Some(id.into()),
776 atoms: vec![AtomIn {
777 index: 0,
778 element: Some("C".into()),
779 z: Some(6),
780 x: 10.0,
781 y: 10.0,
782 label: None,
783 charge: 0,
784 }],
785 bonds: vec![],
786 color: None,
787 atom_shade: None,
788 bond_shade: None,
789 shade_vmin: None,
790 shade_vmax: None,
791 mark_atoms: vec![],
792 mark_bonds: vec![],
793 scale: 1.0,
794 weight: 1.0,
795 },
796 scene: Scene {
797 width: w,
798 height: h,
799 viewports: vec![Viewport {
800 id: Some(id.into()),
801 x: 0.0,
802 y: 0.0,
803 width: w,
804 height: h,
805 layers: vec![Layer {
806 name: LayerName::Bonds,
807 primitives: vec![],
808 }],
809 }],
810 overlays: vec![],
811 halo: vec![],
812 },
813 }
814 }
815
816 #[test]
817 fn compose_two_mol_scheme_has_overlay() {
818 let spec = DepictSpec::ReactionScheme {
819 id: None,
820 children: vec![
821 Node::Mol(MolNode {
822 type_: MolNodeKind::Mol,
823 id: Some("A".into()),
824 smiles: Some("CCO".into()),
825 cxsmiles: None,
826 molfile: None,
827 label: None,
828 star_labels: None,
829 align_to: None,
830 opts: None,
831 color: None,
832 scale: None,
833 weight: None,
834 halo: None,
835 shade: None,
836 }),
837 Node::Text(TextNode {
838 type_: TextNodeKind::Text,
839 id: Some("lab".into()),
840 text: "ox".into(),
841 color: None,
842 scale: None,
843 opts: None,
844 }),
845 Node::Edge(EdgeNode {
846 type_: EdgeNodeKind::Edge,
847 sources: MolIds::One("A".into()),
848 targets: MolIds::One("B".into()),
849 label: Some(crate::doc::Label::Id("lab".into())),
850 edge_routing: None,
851 role: None,
852 arrow: EdgeArrow::Forward,
853 color: None,
854 stroke_width: None,
855 dashed: false,
856 }),
857 Node::Mol(MolNode {
858 type_: MolNodeKind::Mol,
859 id: Some("B".into()),
860 smiles: Some("CC=O".into()),
861 cxsmiles: None,
862 molfile: None,
863 label: None,
864 star_labels: None,
865 align_to: None,
866 opts: None,
867 color: None,
868 scale: None,
869 weight: None,
870 halo: None,
871 shade: None,
872 }),
873 ],
874 layout: Some(LayoutOpts {
875 edge_routing: Some(EdgeRouting::Orthogonal),
876 ..Default::default()
877 }),
878 opts: None,
879 color: None,
880 scale: None,
881 };
882 let paints = vec![paint("A", 80.0, 60.0), paint("B", 80.0, 60.0)];
883 let scene = compose_scheme(&spec, &paints).expect("compose");
884 assert_eq!(scene.viewports.len(), 2);
885 assert!(
886 !scene.overlays.is_empty(),
887 "expected edge overlays, got none"
888 );
889 assert!(scene.width > 80.0);
890 }
891
892 #[test]
893 fn compose_empty_and_group_row() {
894 let empty = compose_scheme(
895 &DepictSpec::ReactionScheme {
896 id: None,
897 children: vec![],
898 layout: None,
899 opts: None,
900 color: None,
901 scale: None,
902 },
903 &[],
904 )
905 .unwrap();
906 assert!(empty.viewports.is_empty());
907
908 let group = DepictSpec::Group {
909 id: None,
910 children: vec![
911 Node::Mol(MolNode {
912 type_: MolNodeKind::Mol,
913 id: Some("a".into()),
914 smiles: Some("C".into()),
915 cxsmiles: None,
916 molfile: None,
917 label: None,
918 star_labels: None,
919 align_to: None,
920 opts: None,
921 color: None,
922 scale: None,
923 weight: None,
924 halo: None,
925 shade: None,
926 }),
927 Node::Mol(MolNode {
928 type_: MolNodeKind::Mol,
929 id: Some("b".into()),
930 smiles: Some("O".into()),
931 cxsmiles: None,
932 molfile: None,
933 label: None,
934 star_labels: None,
935 align_to: None,
936 opts: None,
937 color: None,
938 scale: None,
939 weight: None,
940 halo: None,
941 shade: None,
942 }),
943 ],
944 align: false,
945 opts: None,
946 color: None,
947 scale: None,
948 };
949 let scene = compose_scheme(&group, &[paint("a", 40.0, 30.0), paint("b", 50.0, 30.0)]).unwrap();
950 assert_eq!(scene.viewports.len(), 2);
951 assert!(scene.viewports[1].x > scene.viewports[0].x);
952 }
953
954 #[test]
955 fn translate_and_measure_helpers() {
956 let (w, h) = measure_label("ADH");
957 assert!(w > 10.0 && h > 5.0);
958
959 let p = Primitive::Circle {
960 cx: 1.0,
961 cy: 2.0,
962 r: 3.0,
963 fill: Some("#000".into()),
964 stroke: None,
965 stroke_width: 0.0,
966 opacity: 1.0,
967 class: None,
968 };
969 let t = translate_prim(&p, 10.0, 20.0);
970 match t {
971 Primitive::Circle { cx, cy, .. } => {
972 assert!((cx - 11.0).abs() < 1e-9);
973 assert!((cy - 22.0).abs() < 1e-9);
974 }
975 _ => panic!("expected circle"),
976 }
977
978 let path = Primitive::Path {
979 d: "M 0.00 0.00 L 10.00 5.00".into(),
980 stroke: Some("#222".into()),
981 fill: None,
982 stroke_width: 1.0,
983 opacity: 1.0,
984 stroke_dasharray: None,
985 stroke_linecap: None,
986 class: Some("edge".into()),
987 data_text: None,
988 };
989 let shifted = translate_prim(&path, 1.0, 2.0);
990 match shifted {
991 Primitive::Path { d, .. } => {
992 assert!(d.contains("1.00") && d.contains("2.00"));
993 }
994 _ => panic!("expected path"),
995 }
996
997 let text = Primitive::Text {
998 x: 0.0,
999 y: 0.0,
1000 text: "hi".into(),
1001 fill: "#000".into(),
1002 font_size: 11.0,
1003 anchor: TextAnchor::Middle,
1004 class: None,
1005 };
1006 match translate_prim(&text, 5.0, 6.0) {
1007 Primitive::Text { x, y, .. } => {
1008 assert!((x - 5.0).abs() < 1e-9);
1009 assert!((y - 6.0).abs() < 1e-9);
1010 }
1011 _ => panic!("expected text"),
1012 }
1013 }
1014
1015 #[test]
1016 fn edge_prim_mapping_and_path_coords() {
1017 let path = edge_prim_to_scene(&EdgePrim::Path {
1018 d: "M 1 2".into(),
1019 stroke: "none".into(),
1020 fill: "#222".into(),
1021 stroke_width: 0.0,
1022 stroke_dasharray: None,
1023 class: "head".into(),
1024 });
1025 match path {
1026 Primitive::Path {
1027 stroke, fill, class, ..
1028 } => {
1029 assert!(stroke.is_none());
1030 assert_eq!(fill.as_deref(), Some("#222"));
1031 assert_eq!(class.as_deref(), Some("head"));
1032 }
1033 _ => panic!("path"),
1034 }
1035 let txt = edge_prim_to_scene(&EdgePrim::Text {
1036 x: 1.0,
1037 y: 2.0,
1038 text: "L".into(),
1039 fill: "#222".into(),
1040 font_size: 11.0,
1041 anchor: "start".into(),
1042 class: "label".into(),
1043 });
1044 match txt {
1045 Primitive::Text { anchor, .. } => assert_eq!(anchor, TextAnchor::Start),
1046 _ => panic!("text"),
1047 }
1048 let pts = path_coords("M 0 0 L 3 4");
1049 assert_eq!(pts.len(), 2);
1050 }
1051
1052 #[cfg(feature = "elk")]
1053 #[test]
1054 fn section_and_edge_path_parse() {
1055 let sec = serde_json::json!({
1056 "startPoint": {"x": 0.0, "y": 0.0},
1057 "bendPoints": [{"x": 10.0, "y": 0.0}],
1058 "endPoint": {"x": 10.0, "y": 10.0}
1059 });
1060 let pts = section_points(&sec);
1061 assert_eq!(pts.len(), 3);
1062 let edge = serde_json::json!({
1063 "sections": [sec]
1064 });
1065 let path = edge_path_from_elk(&edge).unwrap();
1066 assert!(path.len() >= 2);
1067 }
1068
1069 #[test]
1070 fn row_placement_and_halo_and_skip_edges() {
1071 let p = row_placement(&[("a".into(), 10.0, 20.0), ("b".into(), 12.0, 40.0)]);
1072 assert!(p.positions["a"].1 > 0.0); assert_eq!(p.positions["b"].1, 0.0);
1074
1075 let mut a = paint("A", 50.0, 40.0);
1076 a.scene.halo.push(Primitive::Circle {
1077 cx: 1.0,
1078 cy: 1.0,
1079 r: 2.0,
1080 fill: Some("#fff".into()),
1081 stroke: None,
1082 stroke_width: 0.0,
1083 opacity: 1.0,
1084 class: Some("halo".into()),
1085 });
1086 let bare = DocPaint {
1088 id: "Z".into(),
1089 molecule: a.molecule.clone(),
1090 scene: Scene {
1091 width: 30.0,
1092 height: 20.0,
1093 viewports: vec![],
1094 overlays: vec![],
1095 halo: vec![],
1096 },
1097 };
1098 let _ = scene_size(&bare.scene);
1099
1100 let spec = DepictSpec::ReactionScheme {
1101 id: None,
1102 children: vec![
1103 Node::Mol(MolNode {
1104 type_: MolNodeKind::Mol,
1105 id: Some("A".into()),
1106 smiles: Some("C".into()),
1107 cxsmiles: None,
1108 molfile: None,
1109 label: None,
1110 star_labels: None,
1111 align_to: None,
1112 opts: None,
1113 color: None,
1114 scale: None,
1115 weight: None,
1116 halo: None,
1117 shade: None,
1118 }),
1119 Node::Mol(MolNode {
1120 type_: MolNodeKind::Mol,
1121 id: Some("B".into()),
1122 smiles: Some("O".into()),
1123 cxsmiles: None,
1124 molfile: None,
1125 label: None,
1126 star_labels: None,
1127 align_to: None,
1128 opts: None,
1129 color: None,
1130 scale: None,
1131 weight: None,
1132 halo: None,
1133 shade: None,
1134 }),
1135 Node::Text(TextNode {
1136 type_: TextNodeKind::Text,
1137 id: Some("lab".into()),
1138 text: " ".into(), color: None,
1140 scale: None,
1141 opts: None,
1142 }),
1143 Node::Edge(EdgeNode {
1144 type_: EdgeNodeKind::Edge,
1145 sources: MolIds::One("A".into()),
1146 targets: MolIds::One("B".into()),
1147 label: Some(crate::doc::Label::Placed(crate::doc::LabelPlacement {
1148 id: "lab".into(),
1149 pos: Some(LabelPos::Below),
1150 })),
1151 edge_routing: Some(EdgeRouting::Splines),
1152 role: None,
1153 arrow: EdgeArrow::Line,
1154 color: Some("#06c".into()),
1155 stroke_width: Some(2.0),
1156 dashed: true,
1157 }),
1158 Node::Edge(EdgeNode {
1160 type_: EdgeNodeKind::Edge,
1161 sources: MolIds::One("A".into()),
1162 targets: MolIds::One("missing".into()),
1163 label: None,
1164 edge_routing: None,
1165 role: None,
1166 arrow: EdgeArrow::Forward,
1167 color: None,
1168 stroke_width: None,
1169 dashed: false,
1170 }),
1171 ],
1172 layout: Some(LayoutOpts {
1173 edge_routing: Some(EdgeRouting::Polyline),
1174 algorithm: Some(crate::doc::LayoutAlgorithm::Layered),
1175 direction: Some(crate::doc::LayoutDirection::Down),
1176 node_spacing: Some(16.0),
1177 layer_spacing: Some(8.0),
1178 }),
1179 opts: None,
1180 color: None,
1181 scale: None,
1182 };
1183 let scene = compose_scheme(&spec, &[a, paint("B", 50.0, 40.0)]).unwrap();
1184 assert!(!scene.halo.is_empty());
1185 assert!(!scene.overlays.is_empty());
1186 assert_eq!(routing_name(EdgeRouting::Orthogonal), "ORTHOGONAL");
1187 assert_eq!(routing_name(EdgeRouting::Splines), "SPLINES");
1188
1189 let (t, _) = resolve_edge_label(
1191 &spec,
1192 &EdgeNode {
1193 type_: EdgeNodeKind::Edge,
1194 sources: MolIds::One("A".into()),
1195 targets: MolIds::One("B".into()),
1196 label: None,
1197 edge_routing: None,
1198 role: None,
1199 arrow: EdgeArrow::Forward,
1200 color: None,
1201 stroke_width: None,
1202 dashed: false,
1203 },
1204 );
1205 assert!(t.is_none());
1206
1207 let empty_edge = serde_json::json!({"sections": []});
1208 assert!(edge_path_from_elk(&empty_edge).is_none());
1209 let multi = serde_json::json!({
1210 "sections": [
1211 {
1212 "startPoint": {"x": 0.0, "y": 0.0},
1213 "endPoint": {"x": 5.0, "y": 0.0}
1214 },
1215 {
1216 "startPoint": {"x": 5.0, "y": 0.0},
1217 "bendPoints": [{"x": 5.0, "y": 5.0}],
1218 "endPoint": {"x": 10.0, "y": 5.0}
1219 }
1220 ]
1221 });
1222 assert!(edge_path_from_elk(&multi).unwrap().len() >= 3);
1223
1224 let mut mr = 0.0;
1225 let mut mb = 0.0;
1226 expand_bounds_prim(
1227 &Primitive::Circle {
1228 cx: 9.0,
1229 cy: 9.0,
1230 r: 2.0,
1231 fill: None,
1232 stroke: None,
1233 stroke_width: 0.0,
1234 opacity: 1.0,
1235 class: None,
1236 },
1237 &mut mr,
1238 &mut mb,
1239 );
1240 assert!(mr > 9.0);
1241 }
1242
1243 #[test]
1244 fn compose_empty_mol_and_anchors() {
1245 let mol = DepictSpec::Mol {
1246 smiles: Some("C".into()),
1247 cxsmiles: None,
1248 molfile: None,
1249 id: None,
1250 label: None,
1251 star_labels: None,
1252 align_to: None,
1253 opts: None,
1254 color: None,
1255 scale: None,
1256 weight: None,
1257 halo: None,
1258 shade: None,
1259 };
1260 let empty = compose_scheme(&mol, &[]).unwrap();
1261 assert!(empty.viewports.is_empty());
1262
1263 assert_eq!(routing_name(EdgeRouting::Polyline), "POLYLINE");
1264 match edge_prim_to_scene(&EdgePrim::Text {
1265 x: 0.0,
1266 y: 0.0,
1267 text: "x".into(),
1268 fill: "#000".into(),
1269 font_size: 10.0,
1270 anchor: "end".into(),
1271 class: "c".into(),
1272 }) {
1273 Primitive::Text {
1274 anchor: TextAnchor::End,
1275 ..
1276 } => {}
1277 _ => panic!("end anchor"),
1278 }
1279
1280 let mut a = paint("A", 40.0, 30.0);
1281 a.scene.halo.push(Primitive::Text {
1282 x: 0.0,
1283 y: 0.0,
1284 text: "h".into(),
1285 fill: "#fff".into(),
1286 font_size: 8.0,
1287 anchor: TextAnchor::Start,
1288 class: None,
1289 });
1290 let row = place_row(&[a, paint("B", 40.0, 30.0)], 8.0);
1291 assert_eq!(row.viewports.len(), 2);
1292 assert!(!row.halo.is_empty());
1293
1294 let rel = translate_prim(
1296 &Primitive::Path {
1297 d: "m 1 2 l 3 4".into(),
1298 stroke: None,
1299 fill: None,
1300 stroke_width: 1.0,
1301 opacity: 1.0,
1302 stroke_dasharray: None,
1303 stroke_linecap: None,
1304 class: None,
1305 data_text: None,
1306 },
1307 10.0,
1308 10.0,
1309 );
1310 match rel {
1311 Primitive::Path { d, .. } => assert!(d.contains('m') || d.contains('l') || d.contains('1')),
1312 _ => panic!("path"),
1313 }
1314
1315 let (none_t, _) = resolve_edge_label(
1316 &DepictSpec::ReactionScheme {
1317 id: None,
1318 children: vec![],
1319 layout: None,
1320 opts: None,
1321 color: None,
1322 scale: None,
1323 },
1324 &EdgeNode {
1325 type_: EdgeNodeKind::Edge,
1326 sources: MolIds::One("A".into()),
1327 targets: MolIds::One("B".into()),
1328 label: Some(crate::doc::Label::Items(vec![])),
1329 edge_routing: None,
1330 role: None,
1331 arrow: EdgeArrow::Forward,
1332 color: None,
1333 stroke_width: None,
1334 dashed: false,
1335 },
1336 );
1337 assert!(none_t.is_none());
1338
1339 let joined = edge_path_from_elk(&serde_json::json!({
1340 "sections": [
1341 {"startPoint":{"x":0,"y":0},"endPoint":{"x":1,"y":0}},
1342 {"startPoint":{"x":2,"y":0},"endPoint":{"x":3,"y":0}}
1343 ]
1344 }))
1345 .unwrap();
1346 assert!(joined.len() >= 3);
1347 }
1348
1349 #[test]
1350 fn compose_mol_root_passthrough() {
1351 let paints = vec![paint("m0", 50.0, 40.0)];
1352 let spec = DepictSpec::Mol {
1353 smiles: Some("C".into()),
1354 cxsmiles: None,
1355 molfile: None,
1356 id: Some("m0".into()),
1357 label: None,
1358 star_labels: None,
1359 align_to: None,
1360 opts: None,
1361 color: None,
1362 scale: None,
1363 weight: None,
1364 halo: None,
1365 shade: None,
1366 };
1367 let scene = compose_scheme(&spec, &paints).unwrap();
1368 assert_eq!(scene.viewports.len(), 1);
1369 assert!((scene.width - 50.0).abs() < 1e-9 || scene.viewports[0].width == 50.0);
1370 }
1371}