1use 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 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}