Skip to main content

termal_export/
lib.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2025-2026 Thomas Junier
3
4mod svg;
5
6use std::ops::Range;
7
8use termal_alignment::{rgb::ResidueColorMap, Alignment};
9
10pub use svg::export_svg;
11
12#[derive(Clone, Debug)]
13pub struct Region {
14    pub rows: Range<usize>,
15    pub cols: Range<usize>,
16}
17
18const GUTTER_WIDTH: f32 = 20.0; // h space between headers and sequences
19
20#[derive(Clone, Debug)]
21pub struct ExportOpts {
22    pub region: Region,
23    pub cell_width: f32,
24    pub cell_height: f32,
25    pub residue_font_size: u32,
26    pub ascent_corr: f32,
27    pub char_width: f32,
28    pub colormap: ResidueColorMap,
29    pub margin_x: f32,
30    pub margin_y: f32,
31    pub cell_frames: bool,
32}
33
34#[derive(Clone, Debug)]
35pub struct Layout {
36    pub grid_width: f32,
37    pub grid_height: f32,
38    pub hdr_txt_width: f32,
39}
40
41pub fn compute_layout(aln: &Alignment, opts: &ExportOpts) -> Layout {
42    let max_hdr_len = aln.headers
43            .iter()
44            .skip(opts.region.rows.start)
45            .take(opts.region.rows.end - opts.region.rows.start)
46            .map(|h| h.len()).max().unwrap_or(0);
47    let hdr_txt_width = max_hdr_len as f32 * opts.char_width + GUTTER_WIDTH;
48
49    Layout {
50        grid_width: hdr_txt_width + (opts.region.cols.end - opts.region.cols.start) as f32 * opts.cell_width,
51        grid_height: (opts.region.rows.end - opts.region.rows.start) as f32 * opts.cell_height,
52        hdr_txt_width,
53    }
54}
55
56// NOTE: this is mainly intended for tests, in which Clap may not be available.
57
58impl Default for ExportOpts {
59    fn default() -> Self {
60        let colormap: ResidueColorMap = ResidueColorMap::aa_lesk();
61        Self {
62            region: Region { rows: (0..10), cols: (0..20) },
63            cell_width: 11.0,
64            cell_height: 12.0,
65            residue_font_size: 14,
66            ascent_corr: 12.0,
67            char_width: 8.0,
68            colormap,
69            margin_x: 10.0,
70            margin_y: 10.0,
71            cell_frames: false,
72        }
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::{compute_layout, export_svg, ExportOpts};
79    use termal_alignment::Alignment;
80    use crate::Region;
81
82    #[test]
83    fn compute_layout_uses_longest_header_and_alignment_size() {
84        let aln = Alignment::from_vecs(
85            vec!["a".to_string(), "long_hdr".to_string()],
86            vec!["ACG".to_string(), "TTA".to_string()],
87        );
88
89        let mut opts = ExportOpts::default();
90        opts.region = Region {
91            rows: 0..aln.num_seq(),
92            cols: 0..aln.aln_len(),
93        };
94
95        let layout = compute_layout(&aln, &opts);
96        assert_eq!(layout.hdr_txt_width, 84.0);
97        assert_eq!(layout.grid_width, 117.0);
98        assert_eq!(layout.grid_height, 24.0);
99    }
100
101    fn test_alignment() -> Alignment {
102        Alignment::from_vecs(
103            vec!["seq0".to_string(), "seq1".to_string(), "seq2".to_string(), "seq3".to_string()],
104            vec!["ACGTACGTACGT".to_string(), "TGCATGCATGCA".to_string(),
105                 "GGGGGGGGGGGG".to_string(), "TTTTTTTTTTTT".to_string()],
106        )
107    }
108
109    fn count_svg_rows(svg: &str) -> usize {
110        svg.matches("<g transform='translate(0,").count()
111    }
112
113    fn count_svg_cols(svg: &str) -> usize {
114        svg.lines().find(|l| l.contains("<rect"))
115            .map(|l| l.matches("<rect").count())
116            .unwrap_or(0)
117    }
118
119    #[test]
120    fn region_full_alignment_exports_all_rows_and_cols() {
121        let aln = test_alignment();
122        let mut opts = ExportOpts::default();
123        opts.region = Region {
124            rows: 0..aln.num_seq(),
125            cols: 0..aln.aln_len(),
126        };
127
128        let layout = compute_layout(&aln, &opts);
129        let mut out = Vec::new();
130        export_svg(&aln, &opts, &layout, &mut out).unwrap();
131        let svg = String::from_utf8(out).unwrap();
132
133        assert_eq!(count_svg_rows(&svg), 4, "should export all 4 sequences");
134        assert_eq!(count_svg_cols(&svg), 12, "should export all 12 columns");
135    }
136
137    #[test]
138    fn region_row_subset_exports_only_specified_rows() {
139        let aln = test_alignment();
140        let mut opts = ExportOpts::default();
141        opts.region = Region {
142            rows: 1..3,  // seq1, seq2
143            cols: 0..aln.aln_len(),
144        };
145
146        let layout = compute_layout(&aln, &opts);
147        let mut out = Vec::new();
148        export_svg(&aln, &opts, &layout, &mut out).unwrap();
149        let svg = String::from_utf8(out).unwrap();
150
151        assert_eq!(count_svg_rows(&svg), 2, "should export 2 rows (1:3)");
152        assert_eq!(count_svg_cols(&svg), 12, "should export all 12 columns");
153        assert!(svg.contains("seq1"), "should contain seq1 header");
154        assert!(svg.contains("seq2"), "should contain seq2 header");
155        assert!(!svg.contains("seq0"), "should not contain seq0");
156        assert!(!svg.contains("seq3"), "should not contain seq3");
157    }
158
159    #[test]
160    fn region_col_subset_exports_only_specified_cols() {
161        let aln = test_alignment();
162        let mut opts = ExportOpts::default();
163        opts.region = Region {
164            rows: 0..aln.num_seq(),
165            cols: 3..9,  // 6 columns
166        };
167
168        let layout = compute_layout(&aln, &opts);
169        let mut out = Vec::new();
170        export_svg(&aln, &opts, &layout, &mut out).unwrap();
171        let svg = String::from_utf8(out).unwrap();
172
173        assert_eq!(count_svg_rows(&svg), 4, "should export all 4 sequences");
174        assert_eq!(count_svg_cols(&svg), 6, "should export 6 columns (3:9)");
175    }
176
177    #[test]
178    fn region_row_and_col_subset_exports_specified_rect() {
179        let aln = test_alignment();
180        let mut opts = ExportOpts::default();
181        opts.region = Region {
182            rows: 1..3,  // 2 rows
183            cols: 2..6,  // 4 columns
184        };
185
186        let layout = compute_layout(&aln, &opts);
187        let mut out = Vec::new();
188        export_svg(&aln, &opts, &layout, &mut out).unwrap();
189        let svg = String::from_utf8(out).unwrap();
190
191        assert_eq!(count_svg_rows(&svg), 2, "should export 2 rows (1:3)");
192        assert_eq!(count_svg_cols(&svg), 4, "should export 4 columns (2:6)");
193        assert!(svg.contains("seq1"), "should contain seq1");
194        assert!(svg.contains("seq2"), "should contain seq2");
195    }
196}