Skip to main content

crabwurcs_snfg/
lib.rs

1use crabwurcs_core::{
2    Monosaccharide, MotifError, ResidueGraph, ResidueKind, classify_residue, find_motif_matches,
3};
4use petgraph::Direction;
5use petgraph::graph::NodeIndex;
6use petgraph::visit::EdgeRef;
7use std::collections::{BTreeSet, HashMap};
8use thiserror::Error;
9
10// ── Error types ────────────────────────────────────────────────────────────
11
12#[derive(Debug, Error)]
13pub enum SnfgError {
14    #[error(
15        "no SNFG symbol for monosaccharide: backbone_len={}, code={}",
16        .0.backbone_length,
17        .0.skeleton_code
18    )]
19    UnknownSymbol(Box<Monosaccharide>),
20
21    #[error(transparent)]
22    Core(#[from] crabwurcs_core::CoreError),
23
24    #[error(transparent)]
25    Motif(#[from] MotifError),
26
27    #[error("failed to parse generated SVG for PNG rendering: {0}")]
28    SvgParse(String),
29
30    #[error("PNG dimensions overflow or are unsupported")]
31    RasterDimensions,
32
33    #[error("could not allocate the PNG raster surface")]
34    RasterAllocation,
35
36    #[error("failed to encode PNG: {0}")]
37    PngEncoding(String),
38}
39
40pub type SnfgResult<T> = Result<T, SnfgError>;
41
42// ── SNFG shapes ────────────────────────────────────────────────────────────
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Shape {
46    Circle,
47    Square,
48    NSquare,
49    Triangle,
50    DividedTriangle,
51    Diamond,
52    FlatDiamond,
53    SplitDiamondTop,    // uronic acid – top half coloured
54    SplitDiamondBottom, // uronic acid – bottom half coloured (IdoA)
55    FlatRectangle,
56    Star,
57    Hexagon,
58    FlatHexagon,
59    Pentagon,
60}
61
62// ── SNFG colours (glycoshape.io / standard SNFG palette) ───────────────────
63
64pub mod colour {
65    pub const WHITE: &str = "#FFFFFF";
66    pub const BLUE: &str = "#0072BC";
67    pub const GREEN: &str = "#00A651";
68    pub const YELLOW: &str = "#FFD400";
69    pub const ORANGE: &str = "#F47920";
70    pub const PINK: &str = "#F69EA1";
71    pub const PURPLE: &str = "#A54399";
72    pub const LIGHT_BLUE: &str = "#8FCCE9";
73    pub const BROWN: &str = "#A17A4D";
74    pub const RED: &str = "#ED1C24";
75
76    pub const GLC: &str = BLUE;
77    pub const GAL: &str = YELLOW;
78    pub const MAN: &str = GREEN;
79    pub const FUC: &str = RED;
80    pub const RHA: &str = GREEN;
81    pub const NEU5AC: &str = PURPLE;
82    pub const NEU5GC: &str = LIGHT_BLUE;
83    pub const KDN: &str = GREEN;
84    pub const IDOA: &str = BROWN;
85    pub const KDO: &str = YELLOW;
86    pub const XYL: &str = ORANGE;
87    pub const GUL: &str = ORANGE;
88    pub const ALT: &str = PINK;
89    pub const ALL: &str = PURPLE;
90    pub const TAL: &str = LIGHT_BLUE;
91    pub const IDO: &str = BROWN;
92    pub const UNKNOWN: &str = WHITE;
93
94    pub const STROKE: &str = "#000000";
95    pub const BOND: &str = "#000000";
96    pub const LINKAGE_TEXT: &str = "#000000";
97}
98
99#[derive(Debug, Clone)]
100pub struct Symbol {
101    pub shape: Shape,
102    pub fill: &'static str,
103    pub label: String,
104}
105
106#[allow(clippy::too_many_arguments)]
107fn hexose_family_symbol(
108    fill: &'static str,
109    neutral: &'static str,
110    acid: &'static str,
111    nac: &'static str,
112    amine: &'static str,
113    has_acid: bool,
114    has_n_mod: bool,
115    has_nac: bool,
116    acid_bottom: bool,
117) -> Symbol {
118    if has_acid {
119        return Symbol {
120            shape: if acid_bottom {
121                Shape::SplitDiamondBottom
122            } else {
123                Shape::SplitDiamondTop
124            },
125            fill,
126            label: acid.into(),
127        };
128    }
129    if has_n_mod {
130        return Symbol {
131            shape: if has_nac {
132                Shape::Square
133            } else {
134                Shape::NSquare
135            },
136            fill,
137            label: if has_nac { nac } else { amine }.into(),
138        };
139    }
140    Symbol {
141        shape: Shape::Circle,
142        fill,
143        label: neutral.into(),
144    }
145}
146
147fn registered_symbol(kind: ResidueKind, display_name: Option<&str>) -> Symbol {
148    use ResidueKind::*;
149    let (shape, fill) = match kind {
150        Hex => (Shape::Circle, colour::WHITE),
151        Glc => (Shape::Circle, colour::BLUE),
152        Man => (Shape::Circle, colour::GREEN),
153        Gal => (Shape::Circle, colour::YELLOW),
154        Gul => (Shape::Circle, colour::ORANGE),
155        Alt => (Shape::Circle, colour::PINK),
156        All => (Shape::Circle, colour::PURPLE),
157        Tal => (Shape::Circle, colour::LIGHT_BLUE),
158        Ido => (Shape::Circle, colour::BROWN),
159
160        HexNAc => (Shape::Square, colour::WHITE),
161        GlcNAc => (Shape::Square, colour::BLUE),
162        ManNAc => (Shape::Square, colour::GREEN),
163        GalNAc => (Shape::Square, colour::YELLOW),
164        GulNAc => (Shape::Square, colour::ORANGE),
165        AltNAc => (Shape::Square, colour::PINK),
166        AllNAc => (Shape::Square, colour::PURPLE),
167        TalNAc => (Shape::Square, colour::LIGHT_BLUE),
168        IdoNAc => (Shape::Square, colour::BROWN),
169
170        HexN => (Shape::NSquare, colour::WHITE),
171        GlcN => (Shape::NSquare, colour::BLUE),
172        ManN => (Shape::NSquare, colour::GREEN),
173        GalN => (Shape::NSquare, colour::YELLOW),
174        GulN => (Shape::NSquare, colour::ORANGE),
175        AltN => (Shape::NSquare, colour::PINK),
176        AllN => (Shape::NSquare, colour::PURPLE),
177        TalN => (Shape::NSquare, colour::LIGHT_BLUE),
178        IdoN => (Shape::NSquare, colour::BROWN),
179
180        HexA => (Shape::SplitDiamondTop, colour::WHITE),
181        GlcA => (Shape::SplitDiamondTop, colour::BLUE),
182        ManA => (Shape::SplitDiamondTop, colour::GREEN),
183        GalA => (Shape::SplitDiamondTop, colour::YELLOW),
184        GulA => (Shape::SplitDiamondTop, colour::ORANGE),
185        AltA => (Shape::SplitDiamondTop, colour::PINK),
186        AllA => (Shape::SplitDiamondTop, colour::PURPLE),
187        TalA => (Shape::SplitDiamondTop, colour::LIGHT_BLUE),
188        IdoA => (Shape::SplitDiamondBottom, colour::BROWN),
189
190        DHex => (Shape::Triangle, colour::WHITE),
191        Qui => (Shape::Triangle, colour::BLUE),
192        Rha => (Shape::Triangle, colour::GREEN),
193        SixDGul => (Shape::Triangle, colour::ORANGE),
194        SixDAlt => (Shape::Triangle, colour::PINK),
195        SixDTal => (Shape::Triangle, colour::LIGHT_BLUE),
196        Fuc => (Shape::Triangle, colour::RED),
197
198        DHexNAc => (Shape::DividedTriangle, colour::WHITE),
199        QuiNAc => (Shape::DividedTriangle, colour::BLUE),
200        RhaNAc => (Shape::DividedTriangle, colour::GREEN),
201        SixDAltNAc => (Shape::DividedTriangle, colour::PINK),
202        SixDTalNAc => (Shape::DividedTriangle, colour::LIGHT_BLUE),
203        FucNAc => (Shape::DividedTriangle, colour::RED),
204
205        DDHex => (Shape::FlatRectangle, colour::WHITE),
206        Oli => (Shape::FlatRectangle, colour::BLUE),
207        Tyv => (Shape::FlatRectangle, colour::GREEN),
208        Abe => (Shape::FlatRectangle, colour::ORANGE),
209        Par => (Shape::FlatRectangle, colour::PINK),
210        Dig => (Shape::FlatRectangle, colour::PURPLE),
211        Col => (Shape::FlatRectangle, colour::LIGHT_BLUE),
212
213        Pen => (Shape::Star, colour::WHITE),
214        Ara => (Shape::Star, colour::GREEN),
215        Lyx => (Shape::Star, colour::YELLOW),
216        Xyl => (Shape::Star, colour::ORANGE),
217        Rib => (Shape::Star, colour::PINK),
218
219        NulO => (Shape::Diamond, colour::WHITE),
220        Kdn => (Shape::Diamond, colour::GREEN),
221        Neu5Ac => (Shape::Diamond, colour::PURPLE),
222        Neu5Gc => (Shape::Diamond, colour::LIGHT_BLUE),
223        Neu => (Shape::Diamond, colour::BROWN),
224        Sia => (Shape::Diamond, colour::RED),
225
226        DDNulO => (Shape::FlatDiamond, colour::WHITE),
227        Pse => (Shape::FlatDiamond, colour::GREEN),
228        Leg => (Shape::FlatDiamond, colour::YELLOW),
229        Aci => (Shape::FlatDiamond, colour::PINK),
230        FourELeg => (Shape::FlatDiamond, colour::LIGHT_BLUE),
231
232        Unknown => (Shape::FlatHexagon, colour::WHITE),
233        Bac => (Shape::FlatHexagon, colour::BLUE),
234        LDManHep => (Shape::FlatHexagon, colour::GREEN),
235        Kdo => (Shape::FlatHexagon, colour::YELLOW),
236        Dha => (Shape::FlatHexagon, colour::ORANGE),
237        DDManHep => (Shape::FlatHexagon, colour::PINK),
238        MurNAc => (Shape::FlatHexagon, colour::PURPLE),
239        MurNGc => (Shape::FlatHexagon, colour::LIGHT_BLUE),
240        Mur => (Shape::FlatHexagon, colour::BROWN),
241
242        Assigned => (Shape::Pentagon, colour::WHITE),
243        Api => (Shape::Pentagon, colour::BLUE),
244        Fru => (Shape::Pentagon, colour::GREEN),
245        Tag => (Shape::Pentagon, colour::YELLOW),
246        Sor => (Shape::Pentagon, colour::ORANGE),
247        Psi => (Shape::Pentagon, colour::PINK),
248    };
249    let label = if kind == Assigned {
250        display_name
251            .and_then(|name| name.chars().find(char::is_ascii_alphabetic))
252            .map(|character| character.to_ascii_uppercase().to_string())
253            .unwrap_or_else(|| "?".into())
254    } else {
255        kind.canonical_name().to_string()
256    };
257    Symbol { shape, fill, label }
258}
259
260// ── Monosaccharide → SNFG symbol ───────────────────────────────────────────
261
262pub fn symbol_for(residue: &Monosaccharide) -> SnfgResult<Symbol> {
263    if let Some(kind) = classify_residue(residue) {
264        return Ok(registered_symbol(kind, residue.display_name.as_deref()));
265    }
266    let skel = &residue.skeleton_code;
267    let bare: String = skel.chars().take_while(|c| c.is_ascii_digit()).collect();
268    let bare_str: &str = bare.as_str();
269
270    let has_nac = residue
271        .modifications
272        .iter()
273        .any(|m| m.descriptor.contains("NCC") || m.descriptor.contains("NC"));
274
275    let has_n_mod = residue.modifications.iter().any(|m| {
276        m.descriptor.contains("NCC") || m.descriptor.contains("NC") || m.descriptor.starts_with('N')
277    });
278
279    let has_ngc = residue
280        .modifications
281        .iter()
282        .any(|m| m.descriptor.contains("NCCO") || m.descriptor.contains("NCO"));
283    let has_deoxy = residue.skeleton_code.ends_with('m') || skel.contains('d');
284    let has_acid = skel.contains('A');
285
286    // Ketohexoses have only three stereogenic backbone digits, so they must
287    // be classified before the aldopentose fallback below.  In particular,
288    // fructofuranose is the green SNFG pentagon used by GlycoShape and
289    // glycowork (the ring form is chemically meaningful, not decoration).
290    if residue.anomeric_prefix.starts_with('h') && bare_str == "122" {
291        return Ok(Symbol {
292            shape: Shape::Pentagon,
293            fill: colour::MAN,
294            label: "Fru".into(),
295        });
296    }
297
298    // L-Sorbose (ketopentose) also uses the 'h' prefix pattern
299    if residue.anomeric_prefix.starts_with('h') && bare_str == "121" {
300        return Ok(Symbol {
301            shape: Shape::Star,
302            fill: colour::MAN,
303            label: "Sor".into(),
304        });
305    }
306
307    // WURCS encodes ulosonic-acid oxidation in the leading `A*` carbon
308    // descriptors rather than in the trailing skeleton string. KDO and Bac
309    // belong to SNFG's flat-hexagon "unknown/other" family.
310    if bare_str == "1122" && residue.anomeric_prefix.starts_with('A') {
311        return Ok(Symbol {
312            shape: Shape::Hexagon,
313            fill: colour::KDO,
314            label: "KDO".into(),
315        });
316    }
317    let n_positions = residue
318        .modifications
319        .iter()
320        .filter(|modification| modification.descriptor.contains("NCC"))
321        .map(|modification| modification.position.0)
322        .collect::<std::collections::HashSet<_>>();
323    if bare_str == "2122" && has_deoxy && n_positions.contains(&2) && n_positions.contains(&4) {
324        return Ok(Symbol {
325            shape: Shape::Hexagon,
326            fill: colour::GLC,
327            label: "Bac".into(),
328        });
329    }
330
331    // Each pair contains D/L-inverted SkeletonCodes. SNFG keeps the family
332    // colour; non-default absolute configuration belongs in the figure legend.
333    match bare_str {
334        "2122" | "1211" => {
335            return Ok(hexose_family_symbol(
336                colour::GLC,
337                "Glc",
338                "GlcA",
339                "GlcNAc",
340                "GlcN",
341                has_acid,
342                has_n_mod,
343                has_nac,
344                false,
345            ));
346        }
347        "2112" | "1221" if !has_deoxy => {
348            return Ok(hexose_family_symbol(
349                colour::GAL,
350                "Gal",
351                "GalA",
352                "GalNAc",
353                "GalN",
354                has_acid,
355                has_n_mod,
356                has_nac,
357                false,
358            ));
359        }
360        "1122" | "2211" if !has_deoxy => {
361            return Ok(hexose_family_symbol(
362                colour::MAN,
363                "Man",
364                "ManA",
365                "ManNAc",
366                "ManN",
367                has_acid,
368                has_n_mod,
369                has_nac,
370                false,
371            ));
372        }
373        "2212" | "1121" => {
374            return Ok(hexose_family_symbol(
375                colour::GUL,
376                "Gul",
377                "GulA",
378                "GulNAc",
379                "GulN",
380                has_acid,
381                has_n_mod,
382                has_nac,
383                false,
384            ));
385        }
386        "1222" | "2111" => {
387            return Ok(hexose_family_symbol(
388                colour::ALT,
389                "Alt",
390                "AltA",
391                "AltNAc",
392                "AltN",
393                has_acid,
394                has_n_mod,
395                has_nac,
396                false,
397            ));
398        }
399        "2222" | "1111" => {
400            return Ok(hexose_family_symbol(
401                colour::ALL,
402                "All",
403                "AllA",
404                "AllNAc",
405                "AllN",
406                has_acid,
407                has_n_mod,
408                has_nac,
409                false,
410            ));
411        }
412        "1112" | "2221" => {
413            return Ok(hexose_family_symbol(
414                colour::TAL,
415                "Tal",
416                "TalA",
417                "TalNAc",
418                "TalN",
419                has_acid,
420                has_n_mod,
421                has_nac,
422                false,
423            ));
424        }
425        "2121" | "1212" => {
426            return Ok(hexose_family_symbol(
427                colour::IDO,
428                "Ido",
429                "IdoA",
430                "IdoNAc",
431                "IdoN",
432                has_acid,
433                has_n_mod,
434                has_nac,
435                true,
436            ));
437        }
438        _ => {}
439    }
440
441    // ── Hexoses ────────────────────────────────────────────────────────
442    match bare_str {
443        "2122" if has_acid => {
444            return Ok(Symbol {
445                shape: Shape::SplitDiamondTop,
446                fill: colour::GLC,
447                label: "GlcA".into(),
448            });
449        }
450        "2121" if has_acid => {
451            return Ok(Symbol {
452                shape: Shape::SplitDiamondBottom,
453                fill: colour::IDOA,
454                label: "IdoA".into(),
455            });
456        }
457        "2122" | "2121" if has_n_mod => {
458            if has_nac {
459                return Ok(Symbol {
460                    shape: Shape::Square,
461                    fill: colour::GLC,
462                    label: "GlcNAc".into(),
463                });
464            } else {
465                return Ok(Symbol {
466                    shape: Shape::NSquare,
467                    fill: colour::GLC,
468                    label: "GlcN".into(),
469                });
470            }
471        }
472        "2122" | "2121" => {
473            return Ok(Symbol {
474                shape: Shape::Circle,
475                fill: colour::GLC,
476                label: "Glc".into(),
477            });
478        }
479        "2112" | "2111" if has_acid => {
480            return Ok(Symbol {
481                shape: Shape::SplitDiamondTop,
482                fill: colour::GAL,
483                label: "GalA".into(),
484            });
485        }
486        "2112" | "2111" if has_n_mod => {
487            if has_nac {
488                return Ok(Symbol {
489                    shape: Shape::Square,
490                    fill: colour::GAL,
491                    label: "GalNAc".into(),
492                });
493            } else {
494                return Ok(Symbol {
495                    shape: Shape::NSquare,
496                    fill: colour::GAL,
497                    label: "GalN".into(),
498                });
499            }
500        }
501        "2112" | "2111" => {
502            return Ok(Symbol {
503                shape: Shape::Circle,
504                fill: colour::GAL,
505                label: "Gal".into(),
506            });
507        }
508        "1221" if has_acid => {
509            return Ok(Symbol {
510                shape: Shape::SplitDiamondTop,
511                fill: colour::MAN,
512                label: "ManA".into(),
513            });
514        }
515        "1221" if has_deoxy => {
516            return Ok(Symbol {
517                shape: Shape::Triangle,
518                fill: colour::FUC,
519                label: "Fuc".into(),
520            });
521        }
522        "1221" if has_n_mod => {
523            if has_nac {
524                return Ok(Symbol {
525                    shape: Shape::Square,
526                    fill: colour::MAN,
527                    label: "ManNAc".into(),
528                });
529            } else {
530                return Ok(Symbol {
531                    shape: Shape::NSquare,
532                    fill: colour::MAN,
533                    label: "ManN".into(),
534                });
535            }
536        }
537        "1221" => {
538            return Ok(Symbol {
539                shape: Shape::Circle,
540                fill: colour::MAN,
541                label: "Man".into(),
542            });
543        }
544        "1122" if has_acid => {
545            return Ok(Symbol {
546                shape: Shape::SplitDiamondTop,
547                fill: colour::MAN,
548                label: "ManA".into(),
549            });
550        }
551        "1122" if has_n_mod => {
552            if has_nac {
553                return Ok(Symbol {
554                    shape: Shape::Square,
555                    fill: colour::MAN,
556                    label: "ManNAc".into(),
557                });
558            } else {
559                return Ok(Symbol {
560                    shape: Shape::NSquare,
561                    fill: colour::MAN,
562                    label: "ManN".into(),
563                });
564            }
565        }
566        "1122" => {
567            return Ok(Symbol {
568                shape: Shape::Circle,
569                fill: colour::MAN,
570                label: "Man".into(),
571            });
572        }
573        "2211" if has_acid => {
574            return Ok(Symbol {
575                shape: Shape::SplitDiamondTop,
576                fill: colour::MAN,
577                label: "ManA".into(),
578            });
579        }
580        "2211" if has_deoxy => {
581            return Ok(Symbol {
582                shape: Shape::Triangle,
583                fill: colour::RHA,
584                label: "Rha".into(),
585            });
586        }
587        "2211" if has_n_mod => {
588            if has_nac {
589                return Ok(Symbol {
590                    shape: Shape::Square,
591                    fill: colour::MAN,
592                    label: "ManNAc".into(),
593                });
594            } else {
595                return Ok(Symbol {
596                    shape: Shape::NSquare,
597                    fill: colour::MAN,
598                    label: "ManN".into(),
599                });
600            }
601        }
602        "2211" => {
603            return Ok(Symbol {
604                shape: Shape::Circle,
605                fill: colour::MAN,
606                label: "Man".into(),
607            });
608        }
609        "1121" => {
610            return Ok(Symbol {
611                shape: Shape::Circle,
612                fill: colour::GUL,
613                label: "Gul".into(),
614            });
615        }
616        "2222" => {
617            return Ok(Symbol {
618                shape: Shape::Circle,
619                fill: colour::ALL,
620                label: "All".into(),
621            });
622        }
623        "2221" => {
624            return Ok(Symbol {
625                shape: Shape::Circle,
626                fill: colour::TAL,
627                label: "Tal".into(),
628            });
629        }
630        _ if bare_str.contains('d') && bare_str.len() <= 5 && has_deoxy => {
631            return Ok(Symbol {
632                shape: Shape::Triangle,
633                fill: colour::FUC,
634                label: "Fuc".into(),
635            });
636        }
637        _ => {}
638    }
639
640    // ── Sialic acids (9‑carbon backbones) ──────────────────────────────
641    if bare_str.len() >= 5 && (bare_str.contains("21122") || bare_str.contains("11212")) {
642        if has_ngc {
643            return Ok(Symbol {
644                shape: Shape::Diamond,
645                fill: colour::NEU5GC,
646                label: "Neu5Gc".into(),
647            });
648        }
649        if has_nac {
650            return Ok(Symbol {
651                shape: Shape::Diamond,
652                fill: colour::NEU5AC,
653                label: "Neu5Ac".into(),
654            });
655        }
656        if residue.modifications.iter().any(|m| m.descriptor == "O") {
657            return Ok(Symbol {
658                shape: Shape::Diamond,
659                fill: colour::KDN,
660                label: "KDN".into(),
661            });
662        }
663        return Ok(Symbol {
664            shape: Shape::Diamond,
665            fill: colour::NEU5AC,
666            label: "Sia".into(),
667        });
668    }
669
670    // ── KDO ────────────────────────────────────────────────────────────
671    if bare_str.len() == 4 && bare_str.contains("1122") && has_acid {
672        return Ok(Symbol {
673            shape: Shape::Diamond,
674            fill: colour::KDO,
675            label: "KDO".into(),
676        });
677    }
678
679    // ── Pentoses ───────────────────────────────────────────────────────
680    // Handle specific pentose types before generic fallback
681    match bare_str {
682        "211" | "122" => {
683            // Ara (arabinose) - use green for furanose forms
684            return Ok(Symbol {
685                shape: Shape::Star,
686                fill: colour::MAN, // green - same as other furanoses
687                label: "Ara".into(),
688            });
689        }
690        "212" => {
691            // Xyl (xylose)
692            return Ok(Symbol {
693                shape: Shape::Star,
694                fill: colour::XYL,
695                label: "Xyl".into(),
696            });
697        }
698        "112" | "221" => {
699            // Lyx and its absolute-configuration inverse use the same family
700            // colour in SNFG.
701            return Ok(Symbol {
702                shape: Shape::Star,
703                fill: colour::YELLOW,
704                label: "Lyx".into(),
705            });
706        }
707        "111" | "222" => {
708            // Rib and its absolute-configuration inverse.
709            return Ok(Symbol {
710                shape: Shape::Star,
711                fill: colour::PINK,
712                label: "Rib".into(),
713            });
714        }
715        "121" => {
716            // Other pentose (Sor, etc.)
717            return Ok(Symbol {
718                shape: Shape::Star,
719                fill: colour::MAN, // green - matches reference
720                label: "?".into(),
721            });
722        }
723        _ if bare_str.len() == 3 => {
724            // Generic pentose fallback
725            return Ok(Symbol {
726                shape: Shape::Star,
727                fill: colour::MAN, // green - matches reference
728                label: "Xyl".into(),
729            });
730        }
731        _ => {}
732    }
733
734    // Composition WURCS commonly uses `xxxx` when stereochemistry is not
735    // specified. The chemical class is still known from the backbone and
736    // N-acetyl substituent, so use neutral Hex/HexNAc SNFG symbols rather
737    // than a star (which denotes a pentose family).
738    if skel.contains("xxxx") {
739        return Ok(Symbol {
740            shape: if has_n_mod {
741                Shape::Square
742            } else {
743                Shape::Circle
744            },
745            fill: colour::UNKNOWN,
746            label: if has_nac { "HexNAc" } else { "Hex" }.into(),
747        });
748    }
749
750    // ── Fallback ───────────────────────────────────────────────────────
751    let (shape, fill) = match bare_str.len() {
752        5 => (Shape::Hexagon, colour::UNKNOWN),
753        4 => (Shape::Pentagon, colour::UNKNOWN),
754        _ => (Shape::Star, colour::UNKNOWN),
755    };
756    Ok(Symbol {
757        shape,
758        fill,
759        label: "?".into(),
760    })
761}
762
763// ── Geometry constants ─────────────────────────────────────────────────────
764
765pub const NODE_R: f64 = 25.0;
766pub const H_SPACING: f64 = 100.0;
767pub const V_SPACING: f64 = 100.0;
768pub const BOND_W: f64 = 4.0;
769pub const LABEL_SIZE: f64 = 20.0;
770pub const PNG_SCALE: u32 = 2;
771
772// ── Options ────────────────────────────────────────────────────────────────
773
774#[derive(Debug, Clone)]
775pub struct SourceNotation {
776    pub format: String,
777    pub value: String,
778}
779
780impl SourceNotation {
781    pub fn new(format: impl Into<String>, value: impl Into<String>) -> Self {
782        Self {
783            format: format.into(),
784            value: value.into(),
785        }
786    }
787}
788
789#[derive(Debug, Clone)]
790pub struct RenderOptions {
791    pub colour: bool,
792    pub show_labels: bool,   // show residue abbreviations inside shapes
793    pub show_linkages: bool, // show linkage positions on bonds
794    pub font_family: String,
795    pub scale: f64,
796    /// Exact source notation supplied by the caller. When absent, metadata
797    /// falls back to source text retained by the parsed graph.
798    pub source_notation: Option<SourceNotation>,
799}
800
801impl Default for RenderOptions {
802    fn default() -> Self {
803        Self {
804            colour: true,
805            show_labels: false, // SNFG convention: shape + colour = identity
806            show_linkages: true,
807            font_family: "Arial, Helvetica, sans-serif".into(),
808            scale: 1.0,
809            source_notation: None,
810        }
811    }
812}
813
814// ── Tree layout ────────────────────────────────────────────────────────────
815
816#[derive(Debug, Clone, Default)]
817struct LayoutInfo {
818    x: f64,
819    y: f64,
820}
821
822/// Recursive tree layout: post‑order, each node centred among children.
823/// Children fan out vertically from the parent's y with `V_SPACING` separation.
824fn compute_layout(graph: &ResidueGraph, root: NodeIndex) -> HashMap<usize, LayoutInfo> {
825    let mut info = HashMap::new();
826    let mut visited = std::collections::HashSet::new();
827    let mut next_leaf = 0usize;
828    layout_subtree(graph, root, 0, &mut next_leaf, &mut info, &mut visited);
829    // WURCS compositions and undefined antennae can contain disconnected
830    // components. Lay every component out instead of silently omitting it.
831    for node in graph.inner().node_indices() {
832        if !visited.contains(&node.index()) {
833            if next_leaf > 0 {
834                next_leaf += 1;
835            }
836            layout_subtree(graph, node, 0, &mut next_leaf, &mut info, &mut visited);
837        }
838    }
839
840    resolve_triangle_collisions(graph, &mut info);
841
842    // centre around y=0
843    let min_y = info.values().map(|li| li.y).fold(f64::MAX, f64::min);
844    let max_y = info.values().map(|li| li.y).fold(f64::MIN, f64::max);
845    let shift = -(min_y + max_y) / 2.0;
846    for li in info.values_mut() {
847        li.y += shift;
848    }
849    info
850}
851
852fn resolve_triangle_collisions(graph: &ResidueGraph, info: &mut HashMap<usize, LayoutInfo>) {
853    let mut branches = graph
854        .inner()
855        .edge_references()
856        .filter(|edge| is_fucose(graph, edge.target()) && is_terminal(graph, edge.target()))
857        .map(|edge| {
858            (
859                edge.source(),
860                edge.target(),
861                edge.weight().parent_position.0,
862            )
863        })
864        .collect::<Vec<_>>();
865    branches.sort_by(|(left_parent, _, _), (right_parent, _, _)| {
866        info[&left_parent.index()]
867            .y
868            .total_cmp(&info[&right_parent.index()].y)
869    });
870
871    for (parent, fucose, linkage_pos) in branches {
872        let parent_layout = info[&parent.index()].clone();
873
874        // Check if this parent has both α3 and α6 triangle children (Fuc) or α2 and α4 triangle children (Rha)
875        let parent_fucose_children: Vec<u8> = graph
876            .inner()
877            .edges_directed(parent, Direction::Outgoing)
878            .filter(|edge| is_fucose(graph, edge.target()) && is_terminal(graph, edge.target()))
879            .map(|edge| edge.weight().parent_position.0)
880            .collect();
881
882        let has_both_alpha3_and_alpha6 =
883            parent_fucose_children.contains(&3) && parent_fucose_children.contains(&6);
884        let has_both_alpha2_and_alpha4 =
885            parent_fucose_children.contains(&2) && parent_fucose_children.contains(&4);
886
887        // Check if this is core fucose - attached to the root GlcNAc (reducing end)
888        let is_core_fucose = linkage_pos == 6
889            && graph.residue(parent).is_some_and(|res| {
890                let skel = &res.skeleton_code;
891                let bare: String = skel.chars().take_while(|c| c.is_ascii_digit()).collect();
892                // Check if this is a GlcNAc (2122 with N-acetyl) AND is the root node
893                bare == "2122"
894                    && res
895                        .modifications
896                        .iter()
897                        .any(|m| m.descriptor.contains("NCC"))
898                    && parent == graph.root().unwrap()
899            });
900
901        // Use the same positioning logic as layout_subtree for consistency
902        let desired_y = if has_both_alpha3_and_alpha6 {
903            parent_layout.y
904                + if linkage_pos == 6 {
905                    -V_SPACING // α6 fucose goes UP when paired with α3
906                } else {
907                    V_SPACING // α3 and other positions go DOWN
908                }
909        } else if has_both_alpha2_and_alpha4 {
910            parent_layout.y
911                + if linkage_pos == 4 {
912                    -V_SPACING // α4 rhamnose goes UP when paired with α2
913                } else {
914                    V_SPACING // α2 and other positions go DOWN
915                }
916        } else if is_core_fucose {
917            parent_layout.y + -V_SPACING // Core α6 fucose defaults to UP
918        } else {
919            parent_layout.y + V_SPACING // Single triangle defaults to DOWN
920        };
921
922        let collision = info.iter().any(|(index, layout)| {
923            *index != fucose.index()
924                && (layout.x - parent_layout.x).abs() < f64::EPSILON
925                && (layout.y - desired_y).abs() < f64::EPSILON
926        });
927        if collision {
928            for (index, layout) in info.iter_mut() {
929                if *index != fucose.index() && layout.y >= desired_y {
930                    layout.y += V_SPACING;
931                }
932            }
933        }
934        info.insert(
935            fucose.index(),
936            LayoutInfo {
937                x: parent_layout.x,
938                y: desired_y,
939            },
940        );
941    }
942}
943
944fn layout_subtree(
945    graph: &ResidueGraph,
946    node: NodeIndex,
947    depth: usize,
948    next_leaf: &mut usize,
949    info: &mut HashMap<usize, LayoutInfo>,
950    visited: &mut std::collections::HashSet<usize>,
951) -> f64 {
952    if !visited.insert(node.index()) {
953        return info.get(&node.index()).map(|li| li.y).unwrap_or(0.0);
954    }
955
956    let mut children: Vec<(NodeIndex, u8)> = graph
957        .inner()
958        .edges_directed(node, Direction::Outgoing)
959        .filter(|edge| edge.weight().repeat.is_none() && !edge.weight().cyclic)
960        .map(|edge| (edge.target(), edge.weight().parent_position.0))
961        .filter(|(child, _)| !visited.contains(&child.index()))
962        .collect();
963    // Standard SNFG branch order places higher acceptor positions above
964    // lower ones in the conventional right-to-left layout (for example the
965    // N-glycan α1-6 arm above α1-3, and β1-4 above β1-2).
966    children.sort_by_key(|(child, position)| (std::cmp::Reverse(*position), child.index()));
967
968    let y = if children.is_empty() {
969        let y = *next_leaf as f64 * V_SPACING;
970        *next_leaf += 1;
971        y
972    } else {
973        let (fucose_children, ordinary_children): (Vec<_>, Vec<_>) = children
974            .into_iter()
975            .partition(|(child, _)| is_fucose(graph, *child) && is_terminal(graph, *child));
976
977        let y = if ordinary_children.is_empty() {
978            let y = *next_leaf as f64 * V_SPACING;
979            *next_leaf += 1;
980            y
981        } else {
982            let child_y = ordinary_children
983                .into_iter()
984                .map(|(child, _)| layout_subtree(graph, child, depth + 1, next_leaf, info, visited))
985                .collect::<Vec<_>>();
986            (child_y[0] + child_y[child_y.len() - 1]) / 2.0
987        };
988
989        // SNFG convention draws terminal fucose/rhamnose vertically aligned with parent.
990        // To prevent overlap when multiple triangles are attached to the same parent,
991        // position them in opposite vertical directions when both are present.
992        let has_both_alpha3_and_alpha6 = fucose_children.iter().any(|(_, pos)| *pos == 3)
993            && fucose_children.iter().any(|(_, pos)| *pos == 6);
994        let has_both_alpha2_and_alpha4 = fucose_children.iter().any(|(_, pos)| *pos == 2)
995            && fucose_children.iter().any(|(_, pos)| *pos == 4);
996
997        for (child, linkage_pos) in fucose_children.into_iter() {
998            visited.insert(child.index());
999            // Check if this is core fucose - attached to the root GlcNAc (reducing end)
1000            let is_core_fucose = linkage_pos == 6
1001                && graph.residue(node).is_some_and(|res| {
1002                    let skel = &res.skeleton_code;
1003                    let bare: String = skel.chars().take_while(|c| c.is_ascii_digit()).collect();
1004                    // Check if this is a GlcNAc (2122 with N-acetyl) AND is the root node
1005                    bare == "2122"
1006                        && res
1007                            .modifications
1008                            .iter()
1009                            .any(|m| m.descriptor.contains("NCC"))
1010                        && node == graph.root().unwrap()
1011                });
1012
1013            let vertical_offset = if has_both_alpha3_and_alpha6 {
1014                if linkage_pos == 6 {
1015                    -V_SPACING // α6 fucose goes UP when paired with α3
1016                } else {
1017                    V_SPACING // α3 and other positions go DOWN
1018                }
1019            } else if has_both_alpha2_and_alpha4 {
1020                if linkage_pos == 4 {
1021                    -V_SPACING // α4 rhamnose goes UP when paired with α2
1022                } else {
1023                    V_SPACING // α2 and other positions go DOWN
1024                }
1025            } else if is_core_fucose {
1026                -V_SPACING // Core α6 fucose defaults to UP
1027            } else {
1028                V_SPACING // Single triangle defaults to DOWN
1029            };
1030            info.insert(
1031                child.index(),
1032                LayoutInfo {
1033                    x: depth as f64,
1034                    y: y + vertical_offset,
1035                },
1036            );
1037        }
1038        y
1039    };
1040    info.insert(node.index(), LayoutInfo { x: depth as f64, y });
1041    y
1042}
1043
1044fn is_fucose(graph: &ResidueGraph, node: NodeIndex) -> bool {
1045    graph
1046        .residue(node)
1047        .and_then(|residue| symbol_for(residue).ok())
1048        .is_some_and(|symbol| symbol.shape == Shape::Triangle)
1049}
1050
1051fn is_terminal(graph: &ResidueGraph, node: NodeIndex) -> bool {
1052    graph
1053        .inner()
1054        .edges_directed(node, Direction::Outgoing)
1055        .all(|edge| edge.weight().repeat.is_some() || edge.weight().cyclic)
1056}
1057
1058// ── Linkage label ──────────────────────────────────────────────────────────
1059
1060fn anomer_char(anom: crabwurcs_core::AnomericSymbol, prefix: &str) -> &'static str {
1061    let c = anom.to_char();
1062    if c != 'x' {
1063        return match c {
1064            'a' => "\u{03B1}",
1065            'b' => "\u{03B2}",
1066            'o' => "o",
1067            _ => "?",
1068        };
1069    }
1070    // fallback: use first char of anomeric_prefix
1071    match prefix.chars().next() {
1072        Some('a') | Some('A') => "\u{03B1}",
1073        Some('b') | Some('B') => "\u{03B2}",
1074        Some('o') | Some('O') => "o",
1075        _ => "?",
1076    }
1077}
1078
1079fn linkage_label_for(
1080    inner: &petgraph::graph::Graph<Monosaccharide, crabwurcs_core::Linkage>,
1081    child: NodeIndex,
1082    linkage: &crabwurcs_core::Linkage,
1083) -> String {
1084    let Some(residue) = inner.node_weight(child) else {
1085        return "?".to_string();
1086    };
1087    let anomer = anomer_char(residue.anomeric_symbol, &residue.anomeric_prefix);
1088    let positions = linkage
1089        .parent_positions()
1090        .map(|position| {
1091            if position.0 == 0 {
1092                "?".to_string()
1093            } else {
1094                position.0.to_string()
1095            }
1096        })
1097        .collect::<Vec<_>>()
1098        .join("/");
1099    let bridge = linkage
1100        .map_code
1101        .as_deref()
1102        .and_then(map_bridge_label)
1103        .map(|label| format!(" · {label}"))
1104        .unwrap_or_default();
1105    format!("{anomer} {positions}{bridge}")
1106}
1107
1108fn map_bridge_label(map_code: &str) -> Option<&'static str> {
1109    match map_code {
1110        "*O*" => Some("Anhydro"),
1111        "*OC^XO*/3CO/6=O/3C" | "*1OC^X*2/3CO/5=O/3C" => Some("Py"),
1112        "*OC^SO*/3CO/6=O/3C" | "*1OC^SO*2/3CO/6=O/3C" => Some("(S)Py"),
1113        "*OC^RO*/3CO/6=O/3C" | "*1OC^RO*2/3CO/6=O/3C" => Some("(R)Py"),
1114        "*OSO*/3=O/3=O" => Some("S"),
1115        "*NS*/3=O/3=O" => Some("NS"),
1116        "*OPO*/3O/3=O" | "*1OP^X*2/3O/3=O" => Some("P"),
1117        "*OPOPO*/5O/5=O/3O/3=O" => Some("PyrP"),
1118        "*1NCCOP^XO*2/6O/6=O" => Some("PEtn"),
1119        "*NCCOP^XOP^X*/8O/8=O/6O/6=O" => Some("PPEtn"),
1120        _ => None,
1121    }
1122}
1123
1124fn map_modification_label(map_code: &str) -> Option<&'static str> {
1125    match map_code {
1126        "*OC" => Some("Me"),
1127        "*OCC/3=O" => Some("Ac"),
1128        "*OSO/3=O/3=O" => Some("S"),
1129        "*NSO/3=O/3=O" => Some("NS"),
1130        "*OPO/3O/3=O" => Some("P"),
1131        "*NCC/3=O" => Some("NAc"),
1132        _ => None,
1133    }
1134}
1135
1136// ── SVG rendering ──────────────────────────────────────────────────────────
1137
1138pub fn render_svg(graph: &ResidueGraph) -> SnfgResult<String> {
1139    render_svg_with_options(graph, &RenderOptions::default())
1140}
1141
1142pub fn render_svg_with_options(graph: &ResidueGraph, opts: &RenderOptions) -> SnfgResult<String> {
1143    render_svg_internal(graph, opts, None)
1144}
1145
1146/// Exact graph elements to keep vivid in a highlighted SNFG rendering.
1147/// Indices are the stable `petgraph` node/edge indices exposed by the source
1148/// `ResidueGraph`; elements not listed here are rendered with the muted SNFG
1149/// palette.
1150#[derive(Debug, Clone, Default, PartialEq, Eq)]
1151pub struct HighlightSelection {
1152    pub node_indices: BTreeSet<usize>,
1153    pub edge_indices: BTreeSet<usize>,
1154}
1155
1156/// Render an SNFG SVG with exactly the supplied graph nodes and edges
1157/// highlighted. This is intended for callers that have already resolved a
1158/// specific occurrence and must not highlight every matching motif.
1159pub fn render_svg_with_selection(
1160    graph: &ResidueGraph,
1161    selection: &HighlightSelection,
1162    opts: &RenderOptions,
1163) -> SnfgResult<String> {
1164    render_svg_internal(graph, opts, Some(selection))
1165}
1166
1167/// Render an SNFG SVG while highlighting the union of every occurrence of
1168/// every supplied motif.
1169///
1170/// Matched nodes and motif-internal edges remain fully opaque. Everything
1171/// else uses GlycoDraw's muted SNFG palette while remaining fully opaque. This
1172/// prevents bonds drawn behind unmatched residues from showing through their
1173/// symbols. An empty motif list is identical to [`render_svg_with_options`].
1174pub fn render_svg_with_motifs(
1175    graph: &ResidueGraph,
1176    motifs: &[ResidueGraph],
1177    opts: &RenderOptions,
1178) -> SnfgResult<String> {
1179    if motifs.is_empty() {
1180        return render_svg_with_options(graph, opts);
1181    }
1182    let mut selection = HighlightSelection::default();
1183    for motif in motifs {
1184        for found in find_motif_matches(graph, motif)? {
1185            selection.node_indices.extend(found.node_indices);
1186            selection.edge_indices.extend(found.edge_indices);
1187        }
1188    }
1189    render_svg_with_selection(graph, &selection, opts)
1190}
1191
1192/// Render an SNFG diagram as a transparent PNG at twice the SVG dimensions.
1193pub fn render_png(graph: &ResidueGraph) -> SnfgResult<Vec<u8>> {
1194    render_png_with_options(graph, &RenderOptions::default())
1195}
1196
1197/// Render an SNFG diagram as a transparent PNG using the supplied SVG
1198/// rendering options.
1199pub fn render_png_with_options(graph: &ResidueGraph, opts: &RenderOptions) -> SnfgResult<Vec<u8>> {
1200    svg_to_png(&render_svg_with_options(graph, opts)?)
1201}
1202
1203/// Render an exact-selection SNFG diagram as a transparent PNG at twice the
1204/// SVG dimensions.
1205pub fn render_png_with_selection(
1206    graph: &ResidueGraph,
1207    selection: &HighlightSelection,
1208    opts: &RenderOptions,
1209) -> SnfgResult<Vec<u8>> {
1210    svg_to_png(&render_svg_with_selection(graph, selection, opts)?)
1211}
1212
1213/// Render a motif-highlighted SNFG diagram as a transparent PNG at twice the
1214/// SVG dimensions.
1215pub fn render_png_with_motifs(
1216    graph: &ResidueGraph,
1217    motifs: &[ResidueGraph],
1218    opts: &RenderOptions,
1219) -> SnfgResult<Vec<u8>> {
1220    svg_to_png(&render_svg_with_motifs(graph, motifs, opts)?)
1221}
1222
1223/// Render a single SNFG symbol as a tightly-cropped, transparent-background
1224/// SVG.
1225///
1226/// Convenient for building legends, icons, or thumbnail galleries — for
1227/// example the per-residue symbol table in this crate's documentation. The
1228/// symbol is selected with the same [`symbol_for`] classification used by the
1229/// full graph renderer, so a standalone symbol always matches what
1230/// [`render_svg`] would draw for a one-residue graph. Pass `show_labels: true`
1231/// in [`RenderOptions`] to draw the residue abbreviation inside the shape.
1232pub fn render_symbol_svg(residue: &Monosaccharide, opts: &RenderOptions) -> SnfgResult<String> {
1233    let symbol = symbol_for(residue)?;
1234    let r = NODE_R * opts.scale;
1235    // Star and diamond outer vertices reach roughly 1.3 × r; leave room for
1236    // the stroke so the shapes are not clipped at the canvas edge.
1237    let extent = r * 1.3 + 4.0 * opts.scale;
1238    let side = extent * 2.0;
1239    let cx = side / 2.0;
1240    let cy = side / 2.0;
1241    let mut svg = format!(
1242        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
1243"#,
1244        w = side,
1245        h = side,
1246    );
1247    draw_shape(&mut svg, &symbol, cx, cy, r, opts);
1248    if opts.show_labels {
1249        let x = cx;
1250        let y = cy;
1251        let ff = opts.font_family.as_str();
1252        let label = escape_xml_text(&symbol.label);
1253        svg.push_str(&format!(
1254            r##"<text x="{x}" y="{y}" font-family="{ff}" font-size="11px" fill="#000" text-anchor="middle" dominant-baseline="central">{label}</text>
1255"##,
1256        ));
1257    }
1258    svg.push_str("</svg>\n");
1259    Ok(svg)
1260}
1261
1262fn svg_to_png(svg: &str) -> SnfgResult<Vec<u8>> {
1263    let mut options = resvg::usvg::Options::default();
1264    options.fontdb_mut().load_system_fonts();
1265    let tree = resvg::usvg::Tree::from_str(svg, &options)
1266        .map_err(|error| SnfgError::SvgParse(error.to_string()))?;
1267    let svg_size = tree.size().to_int_size();
1268    let width = svg_size
1269        .width()
1270        .checked_mul(PNG_SCALE)
1271        .ok_or(SnfgError::RasterDimensions)?;
1272    let height = svg_size
1273        .height()
1274        .checked_mul(PNG_SCALE)
1275        .ok_or(SnfgError::RasterDimensions)?;
1276    let mut pixmap =
1277        resvg::tiny_skia::Pixmap::new(width, height).ok_or(SnfgError::RasterAllocation)?;
1278    let transform = resvg::tiny_skia::Transform::from_scale(PNG_SCALE as f32, PNG_SCALE as f32);
1279    resvg::render(&tree, transform, &mut pixmap.as_mut());
1280    pixmap
1281        .encode_png()
1282        .map_err(|error| SnfgError::PngEncoding(error.to_string()))
1283}
1284
1285fn highlight_class(selected: bool) -> &'static str {
1286    if selected {
1287        "motif-match"
1288    } else {
1289        "motif-dimmed"
1290    }
1291}
1292
1293const MOTIF_DEEMPHASIS_CSS: &str = r##"  .motif-match { opacity: 1; }
1294  .motif-dimmed [fill="#0072BC"] { fill: #CDE7EF; }
1295  .motif-dimmed [fill="#00A651"] { fill: #CDE9DF; }
1296  .motif-dimmed [fill="#FFD400"] { fill: #FFF6DE; }
1297  .motif-dimmed [fill="#F47920"] { fill: #FDE7E0; }
1298  .motif-dimmed [fill="#F69EA1"] { fill: #FDF0F1; }
1299  .motif-dimmed [fill="#A54399"] { fill: #F1E6ED; }
1300  .motif-dimmed [fill="#8FCCE9"] { fill: #EEF8FB; }
1301  .motif-dimmed [fill="#A17A4D"] { fill: #F1E9E5; }
1302  .motif-dimmed [fill="#ED1C24"] { fill: #F7E0E0; }
1303  .motif-dimmed [stroke="#000000"],
1304  .motif-dimmed line { stroke: #D9D9D9; }
1305  .motif-dimmed text { fill: #D9D9D9; }
1306"##;
1307
1308fn render_svg_internal(
1309    graph: &ResidueGraph,
1310    opts: &RenderOptions,
1311    highlights: Option<&HighlightSelection>,
1312) -> SnfgResult<String> {
1313    let inner = graph.inner();
1314    if inner.node_count() == 0 {
1315        return Ok(empty_svg(graph, opts));
1316    }
1317    if graph.is_composition() {
1318        return render_composition_svg(graph, opts, highlights);
1319    }
1320
1321    let root = graph.root().unwrap_or_else(|| NodeIndex::from(0u32));
1322    let layout = compute_layout(graph, root);
1323
1324    let max_depth = layout.values().map(|li| li.x).fold(0.0f64, f64::max);
1325    let min_y = layout.values().map(|li| li.y).fold(f64::MAX, f64::min);
1326    let max_y = layout.values().map(|li| li.y).fold(f64::MIN, f64::max);
1327
1328    let s = opts.scale;
1329    let pad = if graph.undefined_modifications().is_empty() {
1330        70.0 * s
1331    } else {
1332        110.0 * s
1333    };
1334    let canvas_w = (max_depth * H_SPACING * s) + 2.0 * pad;
1335    let canvas_h = (max_y - min_y) * s + 2.0 * pad;
1336
1337    // RL orientation: root at right (x = canvas_w - pad), children extend left
1338    let offset_y = -min_y * s + pad;
1339    let to_canvas = |li: &LayoutInfo| -> (f64, f64) {
1340        (canvas_w - pad - li.x * H_SPACING * s, li.y * s + offset_y)
1341    };
1342
1343    // ── SVG header ────────────────────────────────────────────────────
1344    let highlight_css = if highlights.is_some() {
1345        MOTIF_DEEMPHASIS_CSS
1346    } else {
1347        ""
1348    };
1349    let metadata = svg_metadata(graph, opts);
1350    let mut svg = format!(
1351        r#"<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="snfg-title snfg-desc" viewBox="0 0 {w} {h}" width="{w}" height="{h}">
1352{metadata}<style>
1353  .bond {{ stroke: #000; stroke-width: {bw}; fill: none; stroke-linecap: round; }}
1354  .uncertain {{ stroke: #555; stroke-width: {ubw}; fill: none; stroke-linecap: round; stroke-dasharray: 8 7; }}
1355  .link {{ font-family: {ff}; font-size: {ls}px; fill: #000; text-anchor: middle; dominant-baseline: central; }}
1356  .mod-label {{ font-family: {ff}; font-size: {ls}px; fill: #000; text-anchor: middle; dominant-baseline: central; }}
1357  .node {{ fill: none; }}
1358  .res-label {{ font-family: {ff}; font-size: 11px; fill: #000; text-anchor: middle; dominant-baseline: central; }}
1359{highlight_css}</style>
1360"#,
1361        w = canvas_w,
1362        h = canvas_h,
1363        bw = BOND_W * s,
1364        ubw = 2.5 * s,
1365        ff = opts.font_family,
1366        ls = LABEL_SIZE * s,
1367        highlight_css = highlight_css,
1368        metadata = metadata,
1369    );
1370
1371    // ── Edges and linkage labels ──────────────────────────────────────
1372    for edge in inner.edge_references() {
1373        let (Some(parent_layout), Some(child_layout)) = (
1374            layout.get(&edge.source().index()),
1375            layout.get(&edge.target().index()),
1376        ) else {
1377            continue;
1378        };
1379        let (px, py) = to_canvas(parent_layout);
1380        let (cx, cy) = to_canvas(child_layout);
1381        let class = if edge.weight().repeat.is_some() || edge.weight().cyclic {
1382            "uncertain"
1383        } else {
1384            "bond"
1385        };
1386        if let Some(selection) = highlights {
1387            svg.push_str(&format!(
1388                r#"<g data-edge-index="{}" class="{}">
1389"#,
1390                edge.id().index(),
1391                highlight_class(selection.edge_indices.contains(&edge.id().index()))
1392            ));
1393        }
1394        svg.push_str(&format!(
1395            r#"<line x1="{px}" y1="{py}" x2="{cx}" y2="{cy}" class="{class}"/>
1396"#,
1397        ));
1398        if opts.show_linkages {
1399            draw_linkage_text(
1400                &mut svg,
1401                px,
1402                py,
1403                cx,
1404                cy,
1405                &linkage_label_for(inner, edge.target(), edge.weight()),
1406                s,
1407            );
1408        }
1409        if highlights.is_some() {
1410            svg.push_str("</g>\n");
1411        }
1412    }
1413
1414    for undefined in graph.undefined_linkages() {
1415        let Some(child_layout) = layout.get(&undefined.child.index()) else {
1416            continue;
1417        };
1418        let (cx, cy) = to_canvas(child_layout);
1419        for (candidate_index, parent) in undefined.parents.iter().enumerate() {
1420            let Some(parent_layout) = layout.get(&parent.residue.index()) else {
1421                continue;
1422            };
1423            let (px, py) = to_canvas(parent_layout);
1424            if highlights.is_some() {
1425                svg.push_str(r#"<g class="motif-dimmed" data-undefined-linkage="true">"#);
1426                svg.push('\n');
1427            }
1428            svg.push_str(&format!(
1429                r#"<line x1="{px}" y1="{py}" x2="{cx}" y2="{cy}" class="uncertain"/>
1430"#,
1431            ));
1432            if opts.show_linkages && candidate_index == 0 {
1433                draw_linkage_text(&mut svg, px, py, cx, cy, "?", s);
1434            }
1435            if highlights.is_some() {
1436                svg.push_str("</g>\n");
1437            }
1438        }
1439    }
1440
1441    for modification in graph.undefined_modifications() {
1442        let candidates = modification
1443            .parents
1444            .iter()
1445            .filter_map(|parent| layout.get(&parent.residue.index()))
1446            .map(&to_canvas)
1447            .collect::<Vec<_>>();
1448        if candidates.is_empty() {
1449            continue;
1450        }
1451        let label_x = candidates.iter().map(|(x, _)| *x).fold(f64::MIN, f64::max) + 65.0 * s;
1452        let label_y = candidates.iter().map(|(_, y)| y).sum::<f64>() / candidates.len() as f64;
1453        if highlights.is_some() {
1454            svg.push_str(r#"<g class="motif-dimmed" data-undefined-modification="true">"#);
1455            svg.push('\n');
1456        }
1457        for (parent_x, parent_y) in &candidates {
1458            svg.push_str(&format!(
1459                r#"<line x1="{parent_x}" y1="{parent_y}" x2="{label_x}" y2="{label_y}" class="uncertain"/>
1460"#,
1461            ));
1462        }
1463        let label = map_modification_label(&modification.map_code).unwrap_or("Sub");
1464        svg.push_str(&format!(
1465            r##"<rect x="{}" y="{}" width="{}" height="{}" rx="{}" fill="#fff"/>
1466<text x="{label_x}" y="{label_y}" class="link" data-undefined-modification="true">{{{label}?}}</text>
1467"##,
1468            label_x - 42.0 * s,
1469            label_y - 18.0 * s,
1470            84.0 * s,
1471            36.0 * s,
1472            6.0 * s,
1473        ));
1474        if highlights.is_some() {
1475            svg.push_str("</g>\n");
1476        }
1477    }
1478
1479    // ── Nodes ──────────────────────────────────────────────────────────
1480    for node_idx in inner.node_indices() {
1481        if let (Some(li), Some(residue)) =
1482            (layout.get(&node_idx.index()), inner.node_weight(node_idx))
1483        {
1484            let (cx, cy) = to_canvas(li);
1485            let symbol = symbol_for(residue)?;
1486            if let Some(selection) = highlights {
1487                svg.push_str(&format!(
1488                    r#"<g data-node-index="{}" class="{}">
1489"#,
1490                    node_idx.index(),
1491                    highlight_class(selection.node_indices.contains(&node_idx.index()))
1492                ));
1493            }
1494            draw_shape(&mut svg, &symbol, cx, cy, NODE_R * s, opts);
1495
1496            // Put N- and lower-ring O-sulfates below the symbol and the
1497            // remaining O-sulfates above it.
1498            let mod_labels = build_modification_labels(residue);
1499            if !mod_labels.above.is_empty() {
1500                let mod_label = escape_xml_text(&mod_labels.above);
1501                svg.push_str(&format!(
1502                    "<text x=\"{x}\" y=\"{y}\" class=\"mod-label\">{lbl}</text>\n",
1503                    x = cx,
1504                    y = cy - NODE_R * s - 14.0 * s,
1505                    lbl = mod_label,
1506                ));
1507            }
1508            if !mod_labels.below.is_empty() {
1509                let mod_label = escape_xml_text(&mod_labels.below);
1510                svg.push_str(&format!(
1511                    "<text x=\"{x}\" y=\"{y}\" class=\"mod-label\">{lbl}</text>\n",
1512                    x = cx,
1513                    y = cy + NODE_R * s + 14.0 * s,
1514                    lbl = mod_label,
1515                ));
1516            }
1517
1518            if opts.show_labels || is_assigned_symbol(residue, &symbol) {
1519                let label = escape_xml_text(&symbol.label);
1520                svg.push_str(&format!(
1521                    r#"<text x="{x}" y="{y}" class="res-label">{lbl}</text>
1522"#,
1523                    x = cx,
1524                    y = cy,
1525                    lbl = label,
1526                ));
1527            }
1528            if highlights.is_some() {
1529                svg.push_str("</g>\n");
1530            }
1531        }
1532    }
1533
1534    svg.push_str("</svg>\n");
1535    Ok(svg)
1536}
1537
1538fn render_composition_svg(
1539    graph: &ResidueGraph,
1540    opts: &RenderOptions,
1541    highlights: Option<&HighlightSelection>,
1542) -> SnfgResult<String> {
1543    let mut groups: Vec<(String, Symbol, ModificationLabels, bool, Vec<usize>)> = Vec::new();
1544    for node in graph.inner().node_indices() {
1545        let residue = &graph.inner()[node];
1546        // Composition diagrams normally coalesce identical residues.  Keep
1547        // selected and unselected copies in separate groups so an explicit
1548        // selection remains exact even when the composition contains repeats.
1549        let selected =
1550            highlights.is_some_and(|selection| selection.node_indices.contains(&node.index()));
1551        let key = format!("{residue:?}|selected={selected}");
1552        if let Some((_, _, _, _, nodes)) =
1553            groups.iter_mut().find(|(value, _, _, _, _)| *value == key)
1554        {
1555            nodes.push(node.index());
1556        } else {
1557            let symbol = symbol_for(residue)?;
1558            groups.push((
1559                key,
1560                symbol.clone(),
1561                build_modification_labels(residue),
1562                is_assigned_symbol(residue, &symbol),
1563                vec![node.index()],
1564            ));
1565        }
1566    }
1567
1568    let scale = opts.scale;
1569    let spacing = 155.0 * scale;
1570    let width = (groups.len().max(1) as f64 * spacing) + 50.0 * scale;
1571    let height = 175.0 * scale;
1572    let highlight_css = if highlights.is_some() {
1573        MOTIF_DEEMPHASIS_CSS
1574    } else {
1575        ""
1576    };
1577    let metadata = svg_metadata(graph, opts);
1578    let mut svg = format!(
1579        r#"<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="snfg-title snfg-desc" viewBox="0 0 {width} {height}" width="{width}" height="{height}">
1580{metadata}<style>
1581  .res-label {{ font-family: {font}; font-size: 11px; fill: #000; text-anchor: middle; dominant-baseline: central; }}
1582  .mod-label {{ font-family: {font}; font-size: {mod_size}px; fill: #000; text-anchor: middle; dominant-baseline: central; }}
1583  .count {{ font-family: {font}; font-size: {count_size}px; font-weight: 600; fill: #000; text-anchor: middle; }}
1584{highlight_css}</style>
1585"#,
1586        font = opts.font_family,
1587        mod_size = LABEL_SIZE * scale,
1588        count_size = 18.0 * scale,
1589        highlight_css = highlight_css,
1590        metadata = metadata,
1591    );
1592    for (index, (_, symbol, modification, assigned, nodes)) in groups.iter().enumerate() {
1593        let x = 75.0 * scale + index as f64 * spacing;
1594        let y = 62.0 * scale;
1595        if let Some(selection) = highlights {
1596            let selected = nodes
1597                .iter()
1598                .any(|node| selection.node_indices.contains(node));
1599            let indices = nodes
1600                .iter()
1601                .map(usize::to_string)
1602                .collect::<Vec<_>>()
1603                .join(",");
1604            svg.push_str(&format!(
1605                r#"<g data-node-indices="{indices}" class="{}">
1606"#,
1607                highlight_class(selected)
1608            ));
1609        }
1610        draw_shape(&mut svg, symbol, x, y, NODE_R * scale, opts);
1611        if opts.show_labels || *assigned {
1612            let label = escape_xml_text(&symbol.label);
1613            svg.push_str(&format!(
1614                r#"<text x="{x}" y="{y}" class="res-label">{label}</text>
1615"#,
1616            ));
1617        }
1618        if !modification.above.is_empty() {
1619            let modification = escape_xml_text(&modification.above);
1620            svg.push_str(&format!(
1621                r#"<text x="{x}" y="{}" class="mod-label">{modification}</text>
1622"#,
1623                y - 42.0 * scale
1624            ));
1625        }
1626        if !modification.below.is_empty() {
1627            let modification = escape_xml_text(&modification.below);
1628            svg.push_str(&format!(
1629                r#"<text x="{x}" y="{}" class="mod-label">{modification}</text>
1630"#,
1631                y + 42.0 * scale
1632            ));
1633        }
1634        svg.push_str(&format!(
1635            r#"<text x="{x}" y="{}" class="count">×{count}</text>
1636"#,
1637            y + 75.0 * scale,
1638            count = nodes.len()
1639        ));
1640        if highlights.is_some() {
1641            svg.push_str("</g>\n");
1642        }
1643    }
1644    svg.push_str("</svg>\n");
1645    Ok(svg)
1646}
1647
1648fn is_assigned_symbol(residue: &Monosaccharide, symbol: &Symbol) -> bool {
1649    symbol.shape == Shape::Pentagon
1650        && classify_residue(residue).is_some_and(|kind| kind == ResidueKind::Assigned)
1651}
1652
1653fn draw_linkage_text(
1654    svg: &mut String,
1655    px: f64,
1656    py: f64,
1657    cx: f64,
1658    cy: f64,
1659    label: &str,
1660    scale: f64,
1661) {
1662    let mx = (px + cx) / 2.0;
1663    let my = (py + cy) / 2.0;
1664    let dx = cx - px;
1665    let dy = cy - py;
1666    let len = (dx * dx + dy * dy).sqrt();
1667    let (ox, oy) = if len > 1.0 {
1668        let first = (-dy / len * 14.0 * scale, dx / len * 14.0 * scale);
1669        let second = (dy / len * 14.0 * scale, -dx / len * 14.0 * scale);
1670        if first.1 < 0.0 { first } else { second }
1671    } else {
1672        (0.0, -14.0 * scale)
1673    };
1674    let mut angle = dy.atan2(dx).to_degrees();
1675    if angle > 90.0 {
1676        angle -= 180.0;
1677    } else if angle < -90.0 {
1678        angle += 180.0;
1679    }
1680    let label = escape_xml_text(label);
1681    svg.push_str(&format!(
1682        r#"<text x="0" y="0" class="link" transform="translate({x},{y}) rotate({angle})">{label}</text>
1683"#,
1684        x = mx + ox,
1685        y = my + oy,
1686    ));
1687}
1688
1689fn escape_xml_text(value: &str) -> String {
1690    value
1691        .replace('&', "&amp;")
1692        .replace('<', "&lt;")
1693        .replace('>', "&gt;")
1694        .replace('"', "&quot;")
1695        .replace('\'', "&apos;")
1696}
1697
1698fn retained_source_notation(graph: &ResidueGraph) -> Option<SourceNotation> {
1699    graph
1700        .source_wurcs()
1701        .map(|value| SourceNotation::new("wurcs", value))
1702        .or_else(|| {
1703            graph
1704                .source_iupac()
1705                .map(|value| SourceNotation::new("iupac-condensed", value))
1706        })
1707        .or_else(|| {
1708            graph
1709                .source_iupac_extended()
1710                .map(|value| SourceNotation::new("iupac-extended", value))
1711        })
1712        .or_else(|| {
1713            graph
1714                .source_glycam()
1715                .map(|value| SourceNotation::new("glycam", value))
1716        })
1717}
1718
1719fn svg_metadata(graph: &ResidueGraph, opts: &RenderOptions) -> String {
1720    let iupac = crabwurcs_iupac::write_iupac_condensed_canonical(graph).ok();
1721    let wurcs = crabwurcs_core::write_wurcs_canonical(graph).ok();
1722    let source = opts
1723        .source_notation
1724        .clone()
1725        .or_else(|| retained_source_notation(graph));
1726
1727    let title = iupac
1728        .as_deref()
1729        .filter(|value| !value.is_empty())
1730        .map(|value| format!("SNFG glycan: {value}"))
1731        .unwrap_or_else(|| "SNFG glycan".into());
1732    let desc = match (iupac.as_deref(), wurcs.as_deref()) {
1733        (Some(iupac), Some(wurcs)) => {
1734            format!("Canonical IUPAC condensed: {iupac}. Canonical WURCS: {wurcs}.")
1735        }
1736        (Some(iupac), None) => {
1737            format!("Canonical IUPAC condensed: {iupac}. Canonical WURCS is unavailable.")
1738        }
1739        (None, Some(wurcs)) => {
1740            format!("Canonical IUPAC condensed is unavailable. Canonical WURCS: {wurcs}.")
1741        }
1742        (None, None) => "Canonical IUPAC condensed and canonical WURCS are unavailable.".into(),
1743    };
1744
1745    let mut metadata = format!(
1746        "<title id=\"snfg-title\">{}</title>\n\
1747<desc id=\"snfg-desc\">{}</desc>\n\
1748<metadata id=\"crabwurcs-notations\">\n\
1749  <crabwurcs:notations xmlns:crabwurcs=\"https://github.com/Ojas-Singh/crabWURCS/ns/metadata/1\">\n",
1750        escape_xml_text(&title),
1751        escape_xml_text(&desc),
1752    );
1753    match iupac {
1754        Some(value) => metadata.push_str(&format!(
1755            "    <crabwurcs:iupac-condensed canonical=\"true\" available=\"true\">{}</crabwurcs:iupac-condensed>\n",
1756            escape_xml_text(&value)
1757        )),
1758        None => metadata.push_str(
1759            "    <crabwurcs:iupac-condensed canonical=\"true\" available=\"false\"/>\n",
1760        ),
1761    }
1762    match wurcs {
1763        Some(value) => metadata.push_str(&format!(
1764            "    <crabwurcs:wurcs canonical=\"true\" available=\"true\">{}</crabwurcs:wurcs>\n",
1765            escape_xml_text(&value)
1766        )),
1767        None => {
1768            metadata.push_str("    <crabwurcs:wurcs canonical=\"true\" available=\"false\"/>\n")
1769        }
1770    }
1771    if let Some(source) = source {
1772        metadata.push_str(&format!(
1773            "    <crabwurcs:source format=\"{}\">{}</crabwurcs:source>\n",
1774            escape_xml_text(source.format.trim()),
1775            escape_xml_text(source.value.trim())
1776        ));
1777    }
1778    metadata.push_str("  </crabwurcs:notations>\n</metadata>\n");
1779    metadata
1780}
1781
1782fn empty_svg(graph: &ResidueGraph, opts: &RenderOptions) -> String {
1783    format!(
1784        r##"<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="snfg-title snfg-desc" viewBox="0 0 120 40" width="120" height="40">
1785{}  <text x="10" y="25" font-family="sans-serif" font-size="11" fill="#999">(empty)</text>
1786</svg>
1787"##,
1788        svg_metadata(graph, opts)
1789    )
1790}
1791
1792// ── Modification label ─────────────────────────────────────────────────────
1793
1794#[derive(Clone, Debug, Default, PartialEq, Eq)]
1795struct ModificationLabels {
1796    above: String,
1797    below: String,
1798}
1799
1800/// Build short O-sulfation labels around an SNFG symbol.
1801///
1802/// N-sulfation is written as `NS` below the diagonally divided NSquare.
1803/// O-sulfates at positions 2 and 3 are also placed below the symbol; positions
1804/// 4 and above are placed above it. For example, GlcNS6S yields `6S` above and
1805/// `NS` below, while IdoA2S yields `2S` below.
1806fn build_modification_labels(res: &Monosaccharide) -> ModificationLabels {
1807    let mut above_positions: Vec<u8> = Vec::new();
1808    let mut below_positions: Vec<u8> = Vec::new();
1809    let mut has_n_sulfate = false;
1810
1811    for m in &res.modifications {
1812        let desc = &m.descriptor;
1813        if desc.contains("NSO") {
1814            has_n_sulfate = true;
1815        }
1816        if desc.contains("OSO") {
1817            if m.position.0 <= 3 {
1818                below_positions.push(m.position.0);
1819            } else {
1820                above_positions.push(m.position.0);
1821            }
1822        }
1823    }
1824
1825    above_positions.sort();
1826    above_positions.dedup();
1827    below_positions.sort();
1828    below_positions.dedup();
1829
1830    let format_positions = |positions: Vec<u8>| {
1831        positions
1832            .into_iter()
1833            .map(|position| format!("{position}S"))
1834            .collect::<String>()
1835    };
1836    ModificationLabels {
1837        above: format_positions(above_positions),
1838        below: format!(
1839            "{}{}",
1840            if has_n_sulfate { "NS" } else { "" },
1841            format_positions(below_positions)
1842        ),
1843    }
1844}
1845
1846fn draw_shape(svg: &mut String, sym: &Symbol, cx: f64, cy: f64, r: f64, opts: &RenderOptions) {
1847    let fill = if opts.colour { sym.fill } else { "none" };
1848    let stroke = colour::STROKE;
1849    let sw = 2.0 * opts.scale;
1850
1851    match sym.shape {
1852        Shape::Circle => {
1853            svg.push_str(&format!(
1854                r#"<circle cx="{x}" cy="{y}" r="{r}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>
1855"#,
1856                x = cx, y = cy, r = r, fill = fill, stroke = stroke, sw = sw,
1857            ));
1858        }
1859        Shape::Square => {
1860            let h = r;
1861            svg.push_str(&format!(
1862                r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>
1863"#,
1864                x = cx - h, y = cy - h, w = h * 2.0, h = h * 2.0,
1865                fill = fill, stroke = stroke, sw = sw,
1866            ));
1867        }
1868        Shape::NSquare => {
1869            // white square with coloured top-left triangle (N-modified, non-acetylated)
1870            let h = r;
1871            // white background square
1872            svg.push_str(&format!(
1873                r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="white" stroke="{stroke}" stroke-width="{sw}"/>
1874"#,
1875                x = cx - h, y = cy - h, w = h * 2.0, h = h * 2.0,
1876                stroke = stroke, sw = sw,
1877            ));
1878            // coloured top-left triangle
1879            svg.push_str(&format!(
1880                r#"<polygon points="{x1},{y1} {x2},{y2} {x3},{y3} {x4},{y4}" fill="{fill}" stroke="none"/>
1881"#,
1882                x1 = cx - h, y1 = cy - h,
1883                x2 = cx + h, y2 = cy - h,
1884                x3 = cx + h, y3 = cy + h,
1885                x4 = cx - h, y4 = cy - h,
1886                fill = fill,
1887            ));
1888            // inner dividing lines
1889            svg.push_str(&format!(
1890                r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="1.5"/>
1891"#,
1892                x1 = cx - h, y1 = cy - h,
1893                x2 = cx + h, y2 = cy + h,
1894                stroke = stroke,
1895            ));
1896        }
1897        Shape::Triangle => {
1898            let h = r * 1.732; // equilateral
1899            let w = r;
1900            svg.push_str(&format!(
1901                r#"<polygon points="{x1},{y1} {x2},{y2} {x3},{y3}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>
1902"#,
1903                x1 = cx, y1 = cy - h * 0.667,
1904                x2 = cx - w, y2 = cy + h * 0.333,
1905                x3 = cx + w, y3 = cy + h * 0.333,
1906                fill = fill, stroke = stroke, sw = sw,
1907            ));
1908        }
1909        Shape::DividedTriangle => {
1910            let h = r * 1.732;
1911            let top = cy - h * 0.667;
1912            let bottom = cy + h * 0.333;
1913            svg.push_str(&format!(
1914                r#"<polygon points="{cx},{top} {left},{bottom} {right},{bottom}" fill="white" stroke="{stroke}" stroke-width="{sw}"/>
1915"#,
1916                left = cx - r,
1917                right = cx + r,
1918            ));
1919            svg.push_str(&format!(
1920                r#"<polygon points="{cx},{top} {cx},{bottom} {right},{bottom}" fill="{fill}" stroke="none"/>
1921<line x1="{cx}" y1="{top}" x2="{cx}" y2="{bottom}" stroke="{stroke}" stroke-width="1.5"/>
1922"#,
1923                right = cx + r,
1924            ));
1925        }
1926        Shape::FlatRectangle => {
1927            svg.push_str(&format!(
1928                r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>
1929"#,
1930                x = cx - r,
1931                y = cy - r * 0.38,
1932                w = r * 2.0,
1933                h = r * 0.76,
1934            ));
1935        }
1936        Shape::Diamond => {
1937            let d = r * 1.2;
1938            svg.push_str(&format!(
1939                r#"<polygon points="{x1},{y1} {x2},{y2} {x3},{y3} {x4},{y4}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>
1940"#,
1941                x1 = cx, y1 = cy - d,
1942                x2 = cx + d, y2 = cy,
1943                x3 = cx, y3 = cy + d,
1944                x4 = cx - d, y4 = cy,
1945                fill = fill, stroke = stroke, sw = sw,
1946            ));
1947        }
1948        Shape::FlatDiamond => {
1949            let dx = r * 1.25;
1950            let dy = r * 0.62;
1951            svg.push_str(&format!(
1952                r#"<polygon points="{cx},{top} {right},{cy} {cx},{bottom} {left},{cy}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>
1953"#,
1954                top = cy - dy,
1955                right = cx + dx,
1956                bottom = cy + dy,
1957                left = cx - dx,
1958            ));
1959        }
1960        Shape::SplitDiamondTop => {
1961            // GlcA, GalA, ManA — top half coloured
1962            let d = r * 1.2;
1963            svg.push_str(&format!(
1964                r#"<polygon points="{x1},{y1} {x2},{y2} {x3},{y3} {x4},{y4}" fill="white" stroke="{stroke}" stroke-width="{sw}"/>
1965"#,
1966                x1 = cx, y1 = cy - d, x2 = cx + d, y2 = cy,
1967                x3 = cx, y3 = cy + d, x4 = cx - d, y4 = cy,
1968                stroke = stroke, sw = sw,
1969            ));
1970            // coloured top triangle
1971            svg.push_str(&format!(
1972                r#"<polygon points="{x1},{y1} {x2},{y2} {x3},{y3} {x4},{y4}" fill="{fill}" stroke="none"/>
1973"#,
1974                x1 = cx - d, y1 = cy, x2 = cx, y2 = cy - d,
1975                x3 = cx + d, y3 = cy, x4 = cx - d, y4 = cy,
1976                fill = fill,
1977            ));
1978            // horizontal dividing line
1979            svg.push_str(&format!(
1980                r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="1.5"/>
1981"#,
1982                x1 = cx - d, y1 = cy, x2 = cx + d, y2 = cy, stroke = stroke,
1983            ));
1984        }
1985        Shape::SplitDiamondBottom => {
1986            // IdoA — bottom half coloured (brown)
1987            let d = r * 1.2;
1988            svg.push_str(&format!(
1989                r#"<polygon points="{x1},{y1} {x2},{y2} {x3},{y3} {x4},{y4}" fill="white" stroke="{stroke}" stroke-width="{sw}"/>
1990"#,
1991                x1 = cx, y1 = cy - d, x2 = cx + d, y2 = cy,
1992                x3 = cx, y3 = cy + d, x4 = cx - d, y4 = cy,
1993                stroke = stroke, sw = sw,
1994            ));
1995            // coloured bottom triangle
1996            svg.push_str(&format!(
1997                r#"<polygon points="{x1},{y1} {x2},{y2} {x3},{y3} {x4},{y4}" fill="{fill}" stroke="none"/>
1998"#,
1999                x1 = cx - d, y1 = cy, x2 = cx, y2 = cy + d,
2000                x3 = cx + d, y3 = cy, x4 = cx - d, y4 = cy,
2001                fill = fill,
2002            ));
2003            // horizontal dividing line
2004            svg.push_str(&format!(
2005                r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="1.5"/>
2006"#,
2007                x1 = cx - d, y1 = cy, x2 = cx + d, y2 = cy, stroke = stroke,
2008            ));
2009        }
2010        Shape::Star => {
2011            let outer = r * 1.2;
2012            let inner = r * 0.5;
2013            svg.push_str(&draw_regular_points(cx, cy, outer, inner, 5, 2));
2014            svg.push_str(&format!(
2015                " fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\"/>",
2016                fill, stroke, sw
2017            ));
2018            svg.push('\n');
2019        }
2020        Shape::Hexagon => {
2021            svg.push_str(&draw_regular_points(cx, cy, r * 1.05, r * 1.05, 6, 1));
2022            svg.push_str(&format!(
2023                " fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\"/>",
2024                fill, stroke, sw
2025            ));
2026            svg.push('\n');
2027        }
2028        Shape::FlatHexagon => {
2029            let dx = r * 1.15;
2030            let shoulder = r * 0.72;
2031            let dy = r * 0.58;
2032            svg.push_str(&format!(
2033                r#"<polygon points="{l0},{cy} {l1},{top} {r1},{top} {r0},{cy} {r1},{bottom} {l1},{bottom}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>
2034"#,
2035                l0 = cx - dx,
2036                l1 = cx - shoulder,
2037                r1 = cx + shoulder,
2038                r0 = cx + dx,
2039                top = cy - dy,
2040                bottom = cy + dy,
2041            ));
2042        }
2043        Shape::Pentagon => {
2044            svg.push_str(&draw_regular_points(cx, cy, r * 1.05, r * 1.05, 5, 1));
2045            svg.push_str(&format!(
2046                " fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\"/>",
2047                fill, stroke, sw
2048            ));
2049            svg.push('\n');
2050        }
2051    }
2052}
2053
2054fn draw_regular_points(
2055    cx: f64,
2056    cy: f64,
2057    outer: f64,
2058    inner: f64,
2059    sides: usize,
2060    cycles: usize,
2061) -> String {
2062    let total = sides * cycles;
2063    let mut pts = String::from("<polygon points=\"");
2064    for i in 0..total {
2065        let angle =
2066            2.0 * std::f64::consts::PI * i as f64 / total as f64 - std::f64::consts::FRAC_PI_2;
2067        let r = if i % 2 == 0 { outer } else { inner };
2068        use std::fmt::Write;
2069        write!(&mut pts, "{}", cx + r * angle.cos()).unwrap();
2070        write!(&mut pts, ",{} ", cy + r * angle.sin()).unwrap();
2071    }
2072    pts.pop(); // trailing space
2073    pts.push('"');
2074    pts
2075}
2076
2077// ── Tests ──────────────────────────────────────────────────────────────────
2078
2079#[cfg(test)]
2080mod tests {
2081    use super::*;
2082
2083    #[test]
2084    fn every_glycoshape_molecular_record_renders_with_known_symbols() {
2085        let mut records = 0usize;
2086        let mut unknown = Vec::new();
2087        let corpus_lines = include_str!("../data/glycoshape_notations.tsv")
2088            .lines()
2089            .chain(include_str!("../data/glycoshape_derived_notations.tsv").lines());
2090        for line in corpus_lines {
2091            let wurcs = line.split('\t').next().unwrap();
2092            let graph = crabwurcs_core::parse_wurcs(wurcs).unwrap();
2093            for residue in graph.inner().node_weights() {
2094                let symbol = symbol_for(residue).unwrap();
2095                if symbol.label == "?" {
2096                    unknown.push(format!("{residue:?}"));
2097                }
2098            }
2099            let svg = render_svg(&graph).unwrap();
2100            assert!(svg.contains("role=\"img\""), "{wurcs}");
2101            assert!(svg.contains("viewBox="), "{wurcs}");
2102            records += 1;
2103        }
2104        assert_eq!(records, 938);
2105        unknown.sort();
2106        unknown.dedup();
2107        assert!(unknown.is_empty(), "unknown SNFG symbols: {unknown:?}");
2108    }
2109    use crabwurcs_core::parse_wurcs;
2110
2111    fn parse(wurcs: &str) -> ResidueGraph {
2112        parse_wurcs(wurcs).expect("parse WURCS")
2113    }
2114
2115    #[test]
2116    fn test_symbol_glc() {
2117        let g = parse("WURCS=2.0/2,2,1/[u2122h][a2122h-1b_1-5]/1-2/a4-b1");
2118        let res = g.residue(g.root().unwrap()).unwrap();
2119        let sym = symbol_for(res).unwrap();
2120        assert_eq!(sym.shape, Shape::Circle);
2121        assert_eq!(sym.fill, colour::GLC);
2122    }
2123
2124    #[test]
2125    fn n_and_o_sulfated_glucosamine_uses_nsquare_and_positioned_o_sulfates() {
2126        let g = parse(
2127            "WURCS=2.0/2,2,1/[u2122h_2*NSO/3=O/3=O_6*OSO/3=O/3=O][a2121A-1a_1-5_2*OSO/3=O/3=O]/1-2/a4-b1",
2128        );
2129        let root = g.residue(g.root().unwrap()).unwrap();
2130        let symbol = symbol_for(root).unwrap();
2131        assert_eq!(symbol.shape, Shape::NSquare);
2132
2133        let svg = render_svg(&g).unwrap();
2134        assert!(svg.contains("SNFG glycan: IdoA2S(a1-4)GlcNS6S"));
2135        assert!(svg.contains(">6S</text>"));
2136        assert!(svg.contains(">2S</text>"));
2137        assert!(svg.contains(">NS</text>"));
2138        assert!(!svg.contains(">S6S</text>"));
2139        assert!(svg.contains("class=\"mod-label\""));
2140        assert!(
2141            svg.contains(
2142                ".mod-label { font-family: Arial, Helvetica, sans-serif; font-size: 20px;"
2143            )
2144        );
2145    }
2146
2147    #[test]
2148    fn render_symbol_svg_draws_one_shape_on_a_transparent_canvas() {
2149        let glc = crabwurcs_core::residue_from_kind(ResidueKind::Glc).unwrap();
2150        let svg = render_symbol_svg(&glc, &RenderOptions::default()).unwrap();
2151        assert!(svg.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""));
2152        assert!(svg.contains("<circle"));
2153        assert!(svg.contains("fill=\"#0072BC\""));
2154        // Single-symbol output must not leak the full-graph scaffolding.
2155        assert!(!svg.contains("class=\"bond\""));
2156        assert!(!svg.contains("<metadata"));
2157        assert!(!svg.contains("<title"));
2158
2159        // Labels are opt-in.
2160        let labeled = render_symbol_svg(
2161            &glc,
2162            &RenderOptions {
2163                show_labels: true,
2164                ..RenderOptions::default()
2165            },
2166        )
2167        .unwrap();
2168        assert!(labeled.contains(">Glc</text>"));
2169    }
2170
2171    #[test]
2172    fn test_symbol_glcnac() {
2173        let g = parse("WURCS=2.0/2,2,1/[u2122h_2*NCC/3=O][a2122h-1b_1-5]/1-1-2/a4-b1");
2174        let res = g.residue(g.root().unwrap()).unwrap();
2175        let sym = symbol_for(res).unwrap();
2176        assert_eq!(sym.shape, Shape::Square);
2177        assert_eq!(sym.fill, colour::GLC);
2178    }
2179
2180    #[test]
2181    fn test_symbol_gal() {
2182        let g = parse("WURCS=2.0/2,2,1/[u2112h][a2112h-1b_1-5]/1-2/a3-b1");
2183        let res = g.residue(g.root().unwrap()).unwrap();
2184        let sym = symbol_for(res).unwrap();
2185        assert_eq!(sym.shape, Shape::Circle);
2186        assert_eq!(sym.fill, colour::GAL);
2187    }
2188
2189    #[test]
2190    fn test_symbol_man() {
2191        let g = parse("WURCS=2.0/2,2,1/[u1122h][a1122h-1a_1-5]/1-2/a4-b1");
2192        let res = g.residue(g.root().unwrap()).unwrap();
2193        let sym = symbol_for(res).unwrap();
2194        assert_eq!(sym.shape, Shape::Circle);
2195        assert_eq!(sym.fill, colour::MAN);
2196    }
2197
2198    #[test]
2199    fn rare_hexose_epimers_use_their_snfg_families() {
2200        for (code, label, fill) in [
2201            ("2111", "Alt", colour::ALT),
2202            ("1121", "Gul", colour::GUL),
2203            ("2222", "All", colour::ALL),
2204            ("2221", "Tal", colour::TAL),
2205            ("2121", "Ido", colour::IDO),
2206        ] {
2207            let g = parse(&format!("WURCS=2.0/1,1,0/[u{code}h]/1/"));
2208            let symbol = symbol_for(g.residue(g.root().unwrap()).unwrap()).unwrap();
2209            assert_eq!(symbol.shape, Shape::Circle, "{code}");
2210            assert_eq!(symbol.label, label, "{code}");
2211            assert_eq!(symbol.fill, fill, "{code}");
2212        }
2213    }
2214
2215    #[test]
2216    fn test_symbol_fuc() {
2217        let g = parse("WURCS=2.0/2,2,1/[u1221m][a1221m-1a_1-5]/1-2/a3-b1");
2218        let res = g.residue(g.root().unwrap()).unwrap();
2219        let sym = symbol_for(res).unwrap();
2220        assert_eq!(sym.shape, Shape::Triangle);
2221        assert_eq!(sym.fill, colour::FUC);
2222    }
2223
2224    #[test]
2225    fn test_symbol_xyl_uses_exact_snfg_orange() {
2226        let g = parse("WURCS=2.0/1,1,0/[u212h]/1/");
2227        let res = g.residue(g.root().unwrap()).unwrap();
2228        let sym = symbol_for(res).unwrap();
2229        assert_eq!(sym.shape, Shape::Star);
2230        assert_eq!(sym.fill, "#F47920");
2231    }
2232
2233    #[test]
2234    fn lyx_absolute_configurations_use_yellow() {
2235        for code in ["112", "221"] {
2236            let g = parse(&format!("WURCS=2.0/1,1,0/[u{code}h]/1/"));
2237            let symbol = symbol_for(g.residue(g.root().unwrap()).unwrap()).unwrap();
2238            assert_eq!(symbol.shape, Shape::Star, "{code}");
2239            assert_eq!(symbol.label, "Lyx", "{code}");
2240            assert_eq!(symbol.fill, colour::YELLOW, "{code}");
2241        }
2242    }
2243
2244    #[test]
2245    fn official_registry_covers_every_snfg_shape_and_colour() {
2246        for kind in ResidueKind::ALL {
2247            let symbol = registered_symbol(*kind, Some("Example"));
2248            assert!(!symbol.label.is_empty(), "{kind:?}");
2249        }
2250
2251        for (kind, shape, fill) in [
2252            (ResidueKind::Hex, Shape::Circle, colour::WHITE),
2253            (ResidueKind::Glc, Shape::Circle, colour::BLUE),
2254            (ResidueKind::ManNAc, Shape::Square, colour::GREEN),
2255            (ResidueKind::GalN, Shape::NSquare, colour::YELLOW),
2256            (ResidueKind::IdoA, Shape::SplitDiamondBottom, colour::BROWN),
2257            (ResidueKind::Fuc, Shape::Triangle, colour::RED),
2258            (ResidueKind::QuiNAc, Shape::DividedTriangle, colour::BLUE),
2259            (ResidueKind::Dig, Shape::FlatRectangle, colour::PURPLE),
2260            (ResidueKind::Xyl, Shape::Star, colour::ORANGE),
2261            (ResidueKind::Neu5Gc, Shape::Diamond, colour::LIGHT_BLUE),
2262            (ResidueKind::Leg, Shape::FlatDiamond, colour::YELLOW),
2263            (ResidueKind::MurNAc, Shape::FlatHexagon, colour::PURPLE),
2264            (ResidueKind::Psi, Shape::Pentagon, colour::PINK),
2265        ] {
2266            let symbol = registered_symbol(kind, None);
2267            assert_eq!(symbol.shape, shape, "{kind:?}");
2268            assert_eq!(symbol.fill, fill, "{kind:?}");
2269        }
2270    }
2271
2272    #[test]
2273    fn arbitrary_name_uses_assigned_white_pentagon_and_safe_label() {
2274        let mut residue = crabwurcs_core::residue_from_kind(ResidueKind::Hex).unwrap();
2275        residue.residue_kind = None;
2276        residue.display_name = Some("<foo&bar>".into());
2277        let symbol = symbol_for(&residue).unwrap();
2278        assert_eq!(symbol.shape, Shape::Pentagon);
2279        assert_eq!(symbol.fill, colour::WHITE);
2280        assert_eq!(symbol.label, "F");
2281
2282        let mut graph = ResidueGraph::new();
2283        graph.add_residue(residue);
2284        let svg = render_svg(&graph).unwrap();
2285        assert!(svg.contains(">F</text>"));
2286        assert!(!svg.contains("<foo"));
2287    }
2288
2289    #[test]
2290    fn every_registered_symbol_can_be_rendered() {
2291        for &kind in ResidueKind::ALL {
2292            let mut residue = crabwurcs_core::residue_from_kind(kind)
2293                .unwrap_or_else(|_| crabwurcs_core::residue_from_kind(ResidueKind::Hex).unwrap());
2294            residue.residue_kind = Some(kind);
2295
2296            let mut graph = ResidueGraph::new();
2297            graph.add_residue(residue);
2298            let svg = render_svg(&graph).unwrap();
2299            assert!(
2300                svg.contains("<svg") && svg.contains("</svg>"),
2301                "failed to render {}",
2302                kind.canonical_name()
2303            );
2304        }
2305    }
2306
2307    #[test]
2308    fn dynamic_svg_text_is_xml_escaped() {
2309        assert_eq!(escape_xml_text("<&>\"'"), "&lt;&amp;&gt;&quot;&apos;");
2310    }
2311
2312    #[test]
2313    fn test_symbol_neu5ac() {
2314        let g = parse("WURCS=2.0/2,2,1/[u2112h][Aad21122h-2a_2-6_5*NCC/3=O]/1-2/a3-b2");
2315        let children: Vec<_> = g
2316            .inner()
2317            .neighbors_directed(g.root().unwrap(), Direction::Outgoing)
2318            .collect();
2319        assert!(!children.is_empty());
2320        let neu = g.residue(children[0]).unwrap();
2321        let sym = symbol_for(neu).unwrap();
2322        assert_eq!(sym.shape, Shape::Diamond);
2323        assert_eq!(sym.fill, colour::NEU5AC);
2324    }
2325
2326    #[test]
2327    fn official_sialic_acid_palette_and_fruf_symbol_are_exact() {
2328        let neu5gc = parse("WURCS=2.0/1,1,0/[AUd21122h_5*NCCO/3=O]/1/");
2329        let neu5gc = symbol_for(neu5gc.residue(neu5gc.root().unwrap()).unwrap()).unwrap();
2330        assert_eq!(neu5gc.shape, Shape::Diamond);
2331        assert_eq!(neu5gc.fill, colour::LIGHT_BLUE);
2332
2333        let fructan = parse("WURCS=2.0/2,3,2/[hU122h][ha122h-2b_2-5]/1-2-2/a1-b2_b1-c2");
2334        for residue in fructan.inner().node_weights() {
2335            let symbol = symbol_for(residue).unwrap();
2336            assert_eq!(symbol.shape, Shape::Pentagon);
2337            assert_eq!(symbol.fill, colour::MAN);
2338            assert_eq!(symbol.label, "Fru");
2339        }
2340    }
2341
2342    #[test]
2343    fn test_render_linear() {
2344        let g = parse("WURCS=2.0/2,2,1/[u2112h][a2112h-1b_1-5]/1-2/a3-b1");
2345        let svg = render_svg(&g).unwrap();
2346        assert!(svg.contains("<svg"));
2347        assert!(svg.contains("</svg>"));
2348        assert!(svg.contains("class=\"bond\""));
2349    }
2350
2351    #[test]
2352    fn test_kdo_and_bac_use_reference_flat_hexagons() {
2353        let kdo = parse("WURCS=2.0/1,1,0/[AUd1122h]/1/");
2354        let kdo_symbol = symbol_for(kdo.residue(kdo.root().unwrap()).unwrap()).unwrap();
2355        assert_eq!(kdo_symbol.shape, Shape::FlatHexagon);
2356        assert_eq!(kdo_symbol.fill, colour::KDO);
2357        assert_eq!(kdo_symbol.label, "Kdo");
2358
2359        let bac = parse("WURCS=2.0/1,1,0/[u2122m_2*NCC/3=O_4*NCC/3=O]/1/");
2360        let bac_symbol = symbol_for(bac.residue(bac.root().unwrap()).unwrap()).unwrap();
2361        assert_eq!(bac_symbol.shape, Shape::FlatHexagon);
2362        assert_eq!(bac_symbol.fill, colour::GLC);
2363        assert_eq!(bac_symbol.label, "Bac");
2364    }
2365
2366    #[test]
2367    fn test_render_empty() {
2368        let g = ResidueGraph::new();
2369        let svg = render_svg(&g).unwrap();
2370        assert!(svg.contains("empty"));
2371    }
2372
2373    #[test]
2374    fn test_render_branched() {
2375        let g = parse(
2376            "WURCS=2.0/3,3,2/[u2112h_2*NCC/3=O][a2112h-1a_1-5_2*NCC/3=O][Aad21122h-2a_2-6_5*NCC/3=O]/1-2-3/a3-b1_a6-c2",
2377        );
2378        let svg = render_svg(&g).unwrap();
2379        assert!(svg.contains("<svg"));
2380        let bond_count = svg.matches("class=\"bond\"").count();
2381        assert_eq!(bond_count, 2);
2382        assert!(!svg.contains("<rect class=\"bg\""));
2383        assert!(svg.contains("rotate("));
2384    }
2385
2386    #[test]
2387    fn test_render_map_bridge_labels_its_chemistry() {
2388        let g = parse("WURCS=2.0/2,2,1/[hxh][a2122h-1b_1-5]/1-2/a3n2-b1n1*1NCCOP^XO*2/6O/6=O");
2389        let svg = render_svg(&g).unwrap();
2390        assert!(svg.contains("PEtn"), "{svg}");
2391    }
2392
2393    #[test]
2394    fn test_render_undefined_modification_with_candidate_bonds() {
2395        let g = parse("WURCS=2.0/2,2,1/[u2122h][u2112h]/1-2/a?|b?}*OCC/3=O");
2396        let svg = render_svg(&g).unwrap();
2397        assert!(svg.contains("data-undefined-modification=\"true\""));
2398        assert!(svg.contains("{Ac?}"));
2399        assert_eq!(svg.matches("class=\"uncertain\"").count(), 2);
2400    }
2401
2402    #[test]
2403    fn test_render_complex_n_glycan() {
2404        let g = parse(
2405            "WURCS=2.0/6,8,7/[u2122h_2*NCC/3=O][a1221m-1a_1-5][a2122h-1b_1-5_2*NCC/3=O][a1122h-1b_1-5][a1122h-1a_1-5][a2112h-1b_1-5]/1-2-3-4-5-5-2-6/a3-b1_a4-c1_a6-g1_c4-d1_d3-e1_d6-f1_g4-h1",
2406        );
2407        let svg = render_svg(&g).unwrap();
2408        assert!(svg.contains("<svg"));
2409        assert!(svg.contains("\u{03B1}") || svg.contains("\u{03B2}"));
2410    }
2411
2412    #[test]
2413    fn gs00955_uses_standard_n_glycan_branch_order() {
2414        let g = parse(
2415            "WURCS=2.0/6,12,11/[u2122h_2*NCC/3=O][a2122h-1b_1-5_2*NCC/3=O][a1122h-1b_1-5][a1122h-1a_1-5][a2112h-1b_1-5][a1221m-1a_1-5]/1-2-3-4-2-5-2-5-4-2-6-5/a4-b1_b4-c1_c3-d1_c6-i1_d2-e1_d4-g1_e4-f1_g4-h1_i2-j1_j3-k1_j4-l1",
2416        );
2417        let layout = compute_layout(&g, g.root().unwrap());
2418        let central_man = NodeIndex::new(2);
2419        let arm_y = |position| {
2420            let child = g
2421                .inner()
2422                .edges_directed(central_man, Direction::Outgoing)
2423                .find(|edge| edge.weight().parent_position.0 == position)
2424                .unwrap()
2425                .target();
2426            layout[&child.index()].y
2427        };
2428        assert!(arm_y(6) < arm_y(3), "α1-6 must be above α1-3");
2429
2430        let alpha3_man = NodeIndex::new(3);
2431        let branch_y = |position| {
2432            let child = g
2433                .inner()
2434                .edges_directed(alpha3_man, Direction::Outgoing)
2435                .find(|edge| edge.weight().parent_position.0 == position)
2436                .unwrap()
2437                .target();
2438            layout[&child.index()].y
2439        };
2440        assert!(branch_y(4) < branch_y(2), "β1-4 must be above β1-2");
2441
2442        let fucose: NodeIndex = NodeIndex::new(10);
2443        let fucose_parent: NodeIndex = NodeIndex::new(9);
2444        assert_eq!(
2445            layout[&fucose.index()].x,
2446            layout[&fucose_parent.index()].x,
2447            "core fucose must be vertical at its parent's depth"
2448        );
2449        assert_ne!(
2450            layout[&fucose.index()].y,
2451            layout[&fucose_parent.index()].y,
2452            "core fucose must not overprint its parent"
2453        );
2454    }
2455
2456    #[test]
2457    fn terminal_fucose_reserves_a_lane_instead_of_covering_another_residue() {
2458        let g = parse(
2459            "WURCS=2.0/4,4,3/[u2112h_2*NCC/3=O][a2112h-1b_1-5][a2122h-1b_1-5_2*NCC/3=O][a1221m-1a_1-5]/1-2-3-4/a3-b1_a6-c1_c3-d1",
2460        );
2461        let layout = compute_layout(&g, g.root().unwrap());
2462        let coordinates = layout
2463            .values()
2464            .map(|position| (position.x as i32, position.y.round() as i32))
2465            .collect::<std::collections::HashSet<_>>();
2466        assert_eq!(coordinates.len(), g.node_count());
2467    }
2468
2469    #[test]
2470    fn test_render_undefined_fragment_includes_all_components() {
2471        let g = parse(
2472            "WURCS=2.0/6,11,10/[a2122h-1x_1-5_2*NCC/3=O][a2122h-1b_1-5_2*NCC/3=O][a1122h-1b_1-5][a1122h-1a_1-5][a2112h-1b_1-5][a1221m-1a_1-5]/1-2-3-4-2-5-4-2-6-2-5/a4-b1_a6-i1_b4-c1_c3-d1_c6-g1_d2-e1_e4-f1_g2-h1_j4-k1_j1-d4|d6|g4|g6}",
2473        );
2474        assert_eq!(g.undefined_linkages().len(), 1);
2475        let svg = render_svg_with_options(
2476            &g,
2477            &RenderOptions {
2478                show_labels: true,
2479                ..RenderOptions::default()
2480            },
2481        )
2482        .unwrap();
2483        assert_eq!(svg.matches("class=\"res-label\"").count(), 11);
2484        assert_eq!(svg.matches("class=\"uncertain\"").count(), 2);
2485    }
2486
2487    #[test]
2488    fn test_render_composition_does_not_drop_disconnected_residues() {
2489        let g = parse(
2490            "WURCS=2.0/4,15,0+/[AUd21122h_5*NCC/3=O][uxxxxh_2*NCC/3=O][uxxxxh][u1221m]/1-2-2-2-2-2-2-3-3-3-4-4-4-4-4/",
2491        );
2492        let svg = render_svg_with_options(
2493            &g,
2494            &RenderOptions {
2495                show_labels: true,
2496                ..RenderOptions::default()
2497            },
2498        )
2499        .unwrap();
2500        assert!(svg.contains("aria-labelledby=\"snfg-title snfg-desc\""));
2501        assert_eq!(svg.matches("class=\"count\"").count(), 4);
2502        assert!(svg.contains("×6"));
2503        assert!(svg.contains("×5"));
2504    }
2505
2506    #[test]
2507    fn svg_metadata_contains_canonical_and_original_notations() {
2508        let graph = crabwurcs_iupac::parse_iupac_condensed("Glucose").unwrap();
2509        let svg = render_svg(&graph).unwrap();
2510        assert!(svg.contains("<title id=\"snfg-title\">SNFG glycan: Glc</title>"));
2511        assert!(svg.contains("<metadata id=\"crabwurcs-notations\">"));
2512        assert!(svg.contains(
2513            "<crabwurcs:iupac-condensed canonical=\"true\" available=\"true\">Glc</crabwurcs:iupac-condensed>"
2514        ));
2515        assert!(svg.contains("<crabwurcs:wurcs canonical=\"true\" available=\"true\">"));
2516        assert!(
2517            svg.contains("<crabwurcs:source format=\"iupac-condensed\">Glucose</crabwurcs:source>")
2518        );
2519        assert!(svg.contains("aria-labelledby=\"snfg-title snfg-desc\""));
2520    }
2521
2522    #[test]
2523    fn metadata_is_best_effort_and_escapes_source_text() {
2524        let graph = crabwurcs_iupac::parse_iupac_condensed("Foo").unwrap();
2525        let svg = render_svg_with_options(
2526            &graph,
2527            &RenderOptions {
2528                source_notation: Some(SourceNotation::new("custom<&", "Foo<&")),
2529                ..RenderOptions::default()
2530            },
2531        )
2532        .unwrap();
2533        assert!(svg.contains(
2534            "<crabwurcs:iupac-condensed canonical=\"true\" available=\"true\">Foo</crabwurcs:iupac-condensed>"
2535        ));
2536        assert!(svg.contains("<crabwurcs:wurcs canonical=\"true\" available=\"false\"/>"));
2537        assert!(svg.contains(
2538            "<crabwurcs:source format=\"custom&lt;&amp;\">Foo&lt;&amp;</crabwurcs:source>"
2539        ));
2540    }
2541
2542    #[test]
2543    fn png_is_transparent_rgba_at_twice_the_svg_dimensions() {
2544        let graph = crabwurcs_iupac::parse_iupac_condensed("Glc").unwrap();
2545        let png_bytes = render_png(&graph).unwrap();
2546        assert_eq!(&png_bytes[..8], b"\x89PNG\r\n\x1a\n");
2547
2548        let decoder = png::Decoder::new(std::io::Cursor::new(&png_bytes));
2549        let mut reader = decoder.read_info().unwrap();
2550        let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
2551        let info = reader.next_frame(&mut pixels).unwrap();
2552        assert_eq!(info.width, 280);
2553        assert_eq!(info.height, 280);
2554        assert_eq!(info.color_type, png::ColorType::Rgba);
2555        assert_eq!(
2556            pixels[3], 0,
2557            "top-left background pixel must be transparent"
2558        );
2559    }
2560
2561    #[test]
2562    fn highlighted_png_uses_the_same_motif_rendering_path() {
2563        let graph = crabwurcs_iupac::parse_iupac_condensed("Fuc(a1-3)GlcNAc(b1-4)GlcNAc").unwrap();
2564        let motif = crabwurcs_iupac::parse_iupac_condensed("Fuc(a1-?)GlcNAc").unwrap();
2565        let png = render_png_with_motifs(&graph, &[motif], &RenderOptions::default()).unwrap();
2566        assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
2567    }
2568
2569    #[test]
2570    fn explicit_selection_highlights_only_the_requested_graph_elements() {
2571        let graph =
2572            crabwurcs_iupac::parse_iupac_condensed("Fuc(a1-3)GlcNAc(b1-4)Fuc(a1-3)GlcNAc").unwrap();
2573        let motif = crabwurcs_iupac::parse_iupac_condensed("Fuc(a1-?)GlcNAc").unwrap();
2574        let all = render_svg_with_motifs(&graph, &[motif], &RenderOptions::default()).unwrap();
2575        assert_eq!(all.matches("class=\"motif-match\"").count(), 6);
2576
2577        let mut selection = HighlightSelection::default();
2578        selection.node_indices.extend([0, 1]);
2579        selection.edge_indices.insert(0);
2580        let exact =
2581            render_svg_with_selection(&graph, &selection, &RenderOptions::default()).unwrap();
2582        assert_eq!(exact.matches("class=\"motif-match\"").count(), 3);
2583        assert_eq!(exact.matches("class=\"motif-dimmed\"").count(), 4);
2584        assert!(exact.contains("data-node-index=\"0\" class=\"motif-match\""));
2585        assert!(exact.contains("data-node-index=\"1\" class=\"motif-match\""));
2586        assert!(exact.contains("data-edge-index=\"0\" class=\"motif-match\""));
2587        let png = render_png_with_selection(&graph, &selection, &RenderOptions::default()).unwrap();
2588        assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
2589    }
2590
2591    #[test]
2592    fn dimmed_residue_stays_opaque_over_its_linkage() {
2593        let graph = crabwurcs_iupac::parse_iupac_condensed("Man(b1-4)GlcNAc").unwrap();
2594        let motif = crabwurcs_iupac::parse_iupac_condensed("GlcNAc").unwrap();
2595        let png = render_png_with_motifs(&graph, &[motif], &RenderOptions::default()).unwrap();
2596
2597        let decoder = png::Decoder::new(std::io::Cursor::new(&png));
2598        let mut reader = decoder.read_info().unwrap();
2599        let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
2600        let info = reader.next_frame(&mut pixels).unwrap();
2601
2602        // The linkage terminates at the centre of the dimmed Man symbol. Its
2603        // muted green fill must fully occlude that line.
2604        let x = 140usize;
2605        let y = 140usize;
2606        let offset = (y * info.width as usize + x) * 4;
2607        let pixel = &pixels[offset..offset + 4];
2608        assert_eq!(pixel[3], 255, "dimmed symbols must remain fully opaque");
2609        assert_eq!(
2610            pixel,
2611            [0xCD, 0xE9, 0xDF, 0xFF],
2612            "dimmed Man should use GlycoDraw's exact muted green"
2613        );
2614    }
2615}