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#[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#[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, SplitDiamondBottom, FlatRectangle,
56 Star,
57 Hexagon,
58 FlatHexagon,
59 Pentagon,
60}
61
62pub 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
260pub 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 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 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 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 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 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 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 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 match bare_str {
682 "211" | "122" => {
683 return Ok(Symbol {
685 shape: Shape::Star,
686 fill: colour::MAN, label: "Ara".into(),
688 });
689 }
690 "212" => {
691 return Ok(Symbol {
693 shape: Shape::Star,
694 fill: colour::XYL,
695 label: "Xyl".into(),
696 });
697 }
698 "112" | "221" => {
699 return Ok(Symbol {
702 shape: Shape::Star,
703 fill: colour::YELLOW,
704 label: "Lyx".into(),
705 });
706 }
707 "111" | "222" => {
708 return Ok(Symbol {
710 shape: Shape::Star,
711 fill: colour::PINK,
712 label: "Rib".into(),
713 });
714 }
715 "121" => {
716 return Ok(Symbol {
718 shape: Shape::Star,
719 fill: colour::MAN, label: "?".into(),
721 });
722 }
723 _ if bare_str.len() == 3 => {
724 return Ok(Symbol {
726 shape: Shape::Star,
727 fill: colour::MAN, label: "Xyl".into(),
729 });
730 }
731 _ => {}
732 }
733
734 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 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
763pub 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#[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, pub show_linkages: bool, pub font_family: String,
795 pub scale: f64,
796 pub source_notation: Option<SourceNotation>,
799}
800
801impl Default for RenderOptions {
802 fn default() -> Self {
803 Self {
804 colour: true,
805 show_labels: false, show_linkages: true,
807 font_family: "Arial, Helvetica, sans-serif".into(),
808 scale: 1.0,
809 source_notation: None,
810 }
811 }
812}
813
814#[derive(Debug, Clone, Default)]
817struct LayoutInfo {
818 x: f64,
819 y: f64,
820}
821
822fn 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 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 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 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 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 bare == "2122"
894 && res
895 .modifications
896 .iter()
897 .any(|m| m.descriptor.contains("NCC"))
898 && parent == graph.root().unwrap()
899 });
900
901 let desired_y = if has_both_alpha3_and_alpha6 {
903 parent_layout.y
904 + if linkage_pos == 6 {
905 -V_SPACING } else {
907 V_SPACING }
909 } else if has_both_alpha2_and_alpha4 {
910 parent_layout.y
911 + if linkage_pos == 4 {
912 -V_SPACING } else {
914 V_SPACING }
916 } else if is_core_fucose {
917 parent_layout.y + -V_SPACING } else {
919 parent_layout.y + V_SPACING };
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 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 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 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 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 } else {
1017 V_SPACING }
1019 } else if has_both_alpha2_and_alpha4 {
1020 if linkage_pos == 4 {
1021 -V_SPACING } else {
1023 V_SPACING }
1025 } else if is_core_fucose {
1026 -V_SPACING } else {
1028 V_SPACING };
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
1058fn 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 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
1136pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
1151pub struct HighlightSelection {
1152 pub node_indices: BTreeSet<usize>,
1153 pub edge_indices: BTreeSet<usize>,
1154}
1155
1156pub 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
1167pub 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
1192pub fn render_png(graph: &ResidueGraph) -> SnfgResult<Vec<u8>> {
1194 render_png_with_options(graph, &RenderOptions::default())
1195}
1196
1197pub fn render_png_with_options(graph: &ResidueGraph, opts: &RenderOptions) -> SnfgResult<Vec<u8>> {
1200 svg_to_png(&render_svg_with_options(graph, opts)?)
1201}
1202
1203pub 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
1213pub 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
1223pub fn render_symbol_svg(residue: &Monosaccharide, opts: &RenderOptions) -> SnfgResult<String> {
1233 let symbol = symbol_for(residue)?;
1234 let r = NODE_R * opts.scale;
1235 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 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 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 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 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 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 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('&', "&")
1692 .replace('<', "<")
1693 .replace('>', ">")
1694 .replace('"', """)
1695 .replace('\'', "'")
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#[derive(Clone, Debug, Default, PartialEq, Eq)]
1795struct ModificationLabels {
1796 above: String,
1797 below: String,
1798}
1799
1800fn 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 let h = r;
1871 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 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 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; 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 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 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 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 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 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 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(); pts.push('"');
2074 pts
2075}
2076
2077#[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 assert!(!svg.contains("class=\"bond\""));
2156 assert!(!svg.contains("<metadata"));
2157 assert!(!svg.contains("<title"));
2158
2159 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("<&>\"'"), "<&>"'");
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<&\">Foo<&</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 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}