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