Skip to main content

xpict_core/doc/
compose.rs

1//! Compose a reaction scheme into one [`Scene`]: place painted mols + edge overlays.
2//!
3//! Pass 2 ([`super::render_doc`]) paints each molecule independently. This module
4//! lays them out (ELK when available, else a tight row) and strokes edges via
5//! [`crate::arrows`].
6
7use 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; // ≈ LayoutOpts::DEFAULT_LAYER_SPACING
24
25/// Compose a [`DepictSpec::ReactionScheme`] from per-mol [`DocPaint`] rows.
26///
27/// Non-scheme roots return the first paint scene (or an empty scene).
28pub 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                // Multi-mol group: simple row (no edges).
46                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        // Bare string ids that are not nodes: treat the id itself as label text
510        // when it looks like content (hosts may pass Label::Id("ADH") without a
511        // text node — not valid live ABI, but keep paint useful in tests).
512        None if !id.trim().is_empty() && spec.node_by_id(id.trim()).is_none() => {
513            // Prefer requiring text nodes; skip unknown refs.
514            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    // Best-effort: shift numeric pairs after M/L (absolute). Enough for halo paths.
643    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        // Parse a number starting at c.
657        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                // stop if '-' / '+' not part of exponent
662                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        // Need a following number for M/L pairs.
673        if matches!(cmd, 'M' | 'L' | 'm' | 'l') {
674            // skip whitespace
675            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                    // relative — leave unchanged
706                    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); // shorter node is vertically centered
1073        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        // Empty viewports → scene_size uses scene width/height.
1087        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(), // empty after trim → no ELK label
1139                    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                // Missing endpoint — skipped.
1159                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        // Label resolve edge cases.
1190        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        // Relative path cmds left as-is; covers the else branch.
1295        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}