Skip to main content

termal_export/
svg.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2025-2026 Thomas Junier
3
4use std::{
5    io::{Result, Write},
6    iter::zip,
7};
8
9use itertools::Itertools;
10
11use termal_alignment::Alignment;
12
13use crate::{ExportOpts, Layout};
14
15const RESIDUE_FONT_FAMILY: &str = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"DejaVu Sans Mono\", \"Courier New\", monospace";
16
17pub fn export_svg<W: Write>(
18    aln: &Alignment,
19    opts: &ExportOpts,
20    layout: &Layout,
21    out: &mut W,
22) -> Result<()> {
23    out.write_all(svg_open(layout.grid_width, layout.grid_height).as_bytes())?;
24    write_aln(aln, opts, layout, out)?;
25    out.write_all(svg_close().as_bytes())?;
26    Ok(())
27}
28
29fn svg_open(width: f32, height: f32) -> String {
30    format!(
31        "<?xml version='1.0' encoding='UTF-8'?>
32<svg xmlns='http://www.w3.org/2000/svg' width='{}' height='{}' viewBox='0 0 {} {}'>",
33        width, height, width, height
34    )
35}
36
37fn svg_header(hdr: &str, opts: &ExportOpts) -> String {
38    format!(
39        "<text x='0' y='{}' fill='black' font-family='{}' font-size='{}'>{}</text>",
40        opts.ascent_corr, RESIDUE_FONT_FAMILY, opts.residue_font_size, hdr,
41    )
42}
43
44fn svg_sequence(seq: &str, opts: &ExportOpts) -> String {
45    // let def_color: String = String::from("none");
46    let frame_color = if opts.cell_frames {
47        String::from("black")
48    } else {
49        String::from("none")
50    };
51    let backgrounds = seq
52        .chars()
53        .enumerate()
54        .skip(opts.region.cols.start)
55        .take(opts.region.cols.end - opts.region.cols.start)
56        .map(|(i, c)| {
57            let color_string = opts.colormap.rgb(c as u8).to_hex();
58            format!(
59                "<rect x='{}' y='0' width='{}' height='{}' fill='{}' stroke='{}'/>",
60                i as f32 * opts.cell_width,
61                opts.cell_width,
62                opts.cell_height,
63                color_string,
64                frame_color,
65            )
66        })
67        .join("");
68    let hdr = format!(
69        "<text font-family='{}' font-size='{}'>",
70        RESIDUE_FONT_FAMILY, opts.residue_font_size
71    );
72    let residues = seq
73        .chars()
74        .enumerate()
75        .skip(opts.region.cols.start)
76        .take(opts.region.cols.end - opts.region.cols.start)
77        .map(|(i, c)| {
78            format!(
79                "<tspan x='{}' y='{}'>{}</tspan>",
80                i as f32 * opts.cell_width,
81                opts.ascent_corr,
82                c
83            )
84        })
85        .join("");
86    format!("{}{}{}</text>", backgrounds, hdr, residues)
87}
88
89fn write_aln<W: Write>(
90    aln: &Alignment,
91    opts: &ExportOpts,
92    layout: &Layout,
93    out: &mut W,
94) -> Result<()> {
95    let zipped_aln = zip(aln.headers.iter(), aln.sequences.iter());
96    for (i, (hdr, seq)) in zipped_aln
97        .enumerate()
98        .skip(opts.region.rows.start)
99        .take(opts.region.rows.end - opts.region.rows.start) {
100            writeln!(
101                out,
102                "<g transform='translate(0,{})'>{}<g transform='translate({},0)'>{}</g></g>",
103                i as f32 * opts.cell_height,
104                svg_header(hdr, opts),
105                layout.hdr_txt_width,
106                svg_sequence(seq, opts),
107            )?;
108    };
109    Ok(())
110}
111
112fn svg_close() -> String {
113    "</svg>".to_string()
114}
115
116#[cfg(test)]
117mod tests {
118    use termal_alignment::{rgb::ResidueColorMap, Alignment};
119
120    use super::*;
121
122    fn test_alignment() -> Alignment {
123        Alignment::from_vecs(
124            vec!["seq1".to_string(), "longer".to_string()],
125            vec!["ACG".to_string(), "TTA".to_string()],
126        )
127    }
128
129    fn export_to_string(opts: &ExportOpts) -> String {
130        let aln = test_alignment();
131        let layout = crate::compute_layout(&aln, opts);
132        let mut out = Vec::new();
133        export_svg(&aln, opts, &layout, &mut out).unwrap();
134        String::from_utf8(out).unwrap()
135    }
136
137    #[test]
138    fn test_svg_open() {
139        assert_eq!(
140            svg_open(200.0, 80.0),
141            r"<?xml version='1.0' encoding='UTF-8'?>
142<svg xmlns='http://www.w3.org/2000/svg' width='200' height='80' viewBox='0 0 200 80'>"
143        );
144    }
145
146    #[test]
147    fn test_svg_close() {
148        assert_eq!(svg_close(), "</svg>");
149    }
150
151    #[test]
152    fn export_svg_includes_svg_root_and_closing_tag() {
153        let svg = export_to_string(&ExportOpts::default());
154
155        assert!(svg.starts_with("<?xml version='1.0' encoding='UTF-8'?>\n<svg "));
156        assert!(svg.ends_with("</svg>"));
157    }
158
159    #[test]
160    fn export_svg_writes_headers_and_residues() {
161        let svg = export_to_string(&ExportOpts::default());
162
163        assert!(svg.contains(">seq1</text>"));
164        assert!(svg.contains(">longer</text>"));
165        assert!(svg.contains(">A</tspan>"));
166        assert!(svg.contains(">C</tspan>"));
167        assert!(svg.contains(">G</tspan>"));
168        assert!(svg.contains(">T</tspan>"));
169    }
170
171    #[test]
172    fn export_svg_writes_one_rect_per_residue() {
173        let svg = export_to_string(&ExportOpts::default());
174
175        assert_eq!(svg.matches("<rect ").count(), 6);
176    }
177
178    #[test]
179    fn export_svg_cell_frames_toggle_stroke() {
180        let no_frames = export_to_string(&ExportOpts::default());
181
182        let with_frames = export_to_string(&ExportOpts {
183            cell_frames: true,
184            colormap: ResidueColorMap::aa_lesk(),
185            ..ExportOpts::default()
186        });
187
188        assert!(no_frames.contains("stroke='none'"));
189        assert!(with_frames.contains("stroke='black'"));
190    }
191    #[test]
192    fn export_svg_uses_header_offset_from_layout() {
193        let aln = test_alignment();
194        let opts = ExportOpts::default();
195        let layout = crate::compute_layout(&aln, &opts);
196        let svg = export_to_string(&opts);
197
198        assert!(svg.contains(&format!(
199            "<g transform='translate({},0)'>",
200            layout.hdr_txt_width
201        )));
202    }
203
204    #[test]
205    fn export_svg_emits_one_group_per_sequence() {
206        let aln = test_alignment();
207        let svg = export_to_string(&ExportOpts::default());
208
209        assert_eq!(
210            svg.matches("<g transform='translate(0,").count(),
211            aln.num_seq()
212        );
213    }
214
215    #[test]
216    fn export_svg_positions_rows_by_cell_height() {
217        let opts = ExportOpts::default();
218        let svg = export_to_string(&opts);
219
220        assert!(svg.contains("<g transform='translate(0,0)'>"));
221        assert!(svg.contains(&format!(
222            "<g transform='translate(0,{})'>",
223            opts.cell_height
224        )));
225    }
226
227    #[test]
228    fn export_svg_uses_residue_x_positions_from_cell_width() {
229        let opts = ExportOpts::default();
230        let svg = export_to_string(&opts);
231
232        assert!(svg.contains(&format!("<tspan x='0' y='{}'>A</tspan>", opts.ascent_corr)));
233        assert!(svg.contains(&format!(
234            "<tspan x='{}' y='{}'>C</tspan>",
235            opts.cell_width, opts.ascent_corr
236        )));
237        assert!(svg.contains(&format!(
238            "<tspan x='{}' y='{}'>G</tspan>",
239            2.0 * opts.cell_width,
240            opts.ascent_corr
241        )));
242    }
243
244    #[test]
245    fn export_svg_uses_residue_font_size_and_ascent() {
246        let opts = ExportOpts {
247            residue_font_size: 17,
248            ascent_corr: 9.5,
249            ..ExportOpts::default()
250        };
251        let svg = export_to_string(&opts);
252
253        assert!(svg.contains("font-size='17'"));
254        assert!(svg.contains("<text x='0' y='9.5'"));
255        assert!(svg.contains("<tspan x='0' y='9.5'>A</tspan>"));
256    }
257}