1use std::io::{self, Write};
11
12use chrono::Local;
13
14use crate::config::TemplateName;
15use crate::modules::QCModule;
16use crate::report::charts::png_to_data_uri;
17
18pub fn generate_html_report(
22 modules: &[Box<dyn QCModule>],
23 filename: &str,
24 template_name: TemplateName,
25) -> io::Result<String> {
26 let template = crate::report::templates::create_template(template_name);
27 let mut buf = Vec::new();
28 template.write_html_report(modules, filename, &mut buf)?;
29 String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
30}
31
32pub fn write_chart(
39 module: &(impl crate::modules::QCModule + ?Sized),
40 alt_text: &str,
41 w: &mut dyn Write,
42) -> io::Result<()> {
43 use crate::report::charts::{svg_to_png, CHART_HEIGHT, CHART_WIDTH};
44
45 if let Some(svg) = module.generate_chart_svg() {
46 let png_bytes =
47 svg_to_png(&svg, CHART_WIDTH as u32, CHART_HEIGHT as u32).map_err(io::Error::other)?;
48 let data_uri = png_to_data_uri(&png_bytes);
49 write!(
50 w,
51 "<p><img class=\"indented\" src=\"{}\" alt=\"{}\"/></p>",
52 data_uri, alt_text,
53 )?;
54 }
55
56 Ok(())
57}
58
59pub fn write_chart_svg(
65 module: &(impl crate::modules::QCModule + ?Sized),
66 w: &mut dyn Write,
67) -> io::Result<()> {
68 if let Some(svg) = module.generate_chart_svg() {
69 write!(w, "<p>{}</p>", minify_svg(&svg))?;
70 }
71 Ok(())
72}
73
74fn minify_svg(svg: &str) -> String {
84 let mut out = String::with_capacity(svg.len());
85
86 for line in svg.lines() {
88 let t = line.trim();
89 if t.starts_with("<?xml") || t.starts_with("<!DOCTYPE") {
90 continue;
91 }
92
93 if t.starts_with("<svg ") {
95 out.push_str(&t.replacen("<svg ", "<svg style=\"max-width:100%\" ", 1));
96 out.push('\n');
97 out.push_str(
98 "<style>\
99 .ce{shape-rendering:crispEdges}\
100 text{font-family:'Liberation Sans',Arial,Helvetica,sans-serif}\
101 </style>\n",
102 );
103 continue;
104 }
105
106 if t.starts_with("<line ") {
108 out.push_str(t);
109 out.push('\n');
110 continue;
111 }
112
113 let shortened = t
115 .replace(" shape-rendering=\"crispEdges\"", " class=\"ce\"")
116 .replace(
117 " font-family=\"'Liberation Sans', Arial, Helvetica, sans-serif\"",
118 "",
119 );
120 let shortened = shortened
122 .replace("style=\"fill:rgb(", "fill=\"rgb(")
123 .replace(");stroke:none\"", ")\"");
124 let shortened = shortened.replace(
126 "style=\"fill:none;stroke-width:1;stroke:",
127 "fill=\"none\" stroke=\"",
128 );
129 out.push_str(&shortened);
130 out.push('\n');
131 }
132
133 merge_lines_to_polylines(&mut out);
135 rle_merge_rects(&mut out);
136 out
137}
138
139fn merge_lines_to_polylines(svg: &mut String) {
145 struct LineSeg {
146 x1: String,
147 y1: String,
148 x2: String,
149 y2: String,
150 }
151
152 let mut groups: Vec<(usize, usize, String, String, Vec<LineSeg>)> = Vec::new();
154 let mut current_group: Vec<LineSeg> = Vec::new();
155 let mut group_start = 0;
156 let mut group_end = 0;
157 let mut last_stroke = "";
158 let mut last_width = "";
159
160 let mut search_from = 0;
161 while let Some(start) = svg[search_from..].find("<line ") {
162 let abs_start = search_from + start;
163 let Some(end) = svg[abs_start..].find("/>") else {
164 break;
165 };
166 let abs_end = abs_start + end + 2;
167 let tag = &svg[abs_start..abs_end];
168
169 let stroke = extract_attr(tag, "stroke");
170 let width = extract_attr(tag, "stroke-width");
171 let is_grid = stroke == "rgb(180,180,180)" || stroke == "rgb(0,0,0)";
172
173 if !is_grid && stroke == last_stroke && width == last_width {
174 current_group.push(LineSeg {
175 x1: extract_attr(tag, "x1").to_string(),
176 y1: extract_attr(tag, "y1").to_string(),
177 x2: extract_attr(tag, "x2").to_string(),
178 y2: extract_attr(tag, "y2").to_string(),
179 });
180 group_end = abs_end;
181 } else {
182 if current_group.len() > 2 {
183 groups.push((
184 group_start,
185 group_end,
186 last_stroke.to_string(),
187 last_width.to_string(),
188 std::mem::take(&mut current_group),
189 ));
190 } else {
191 current_group.clear();
192 }
193 if !is_grid {
194 group_start = abs_start;
195 group_end = abs_end;
196 last_stroke = stroke;
197 last_width = width;
198 current_group.push(LineSeg {
199 x1: extract_attr(tag, "x1").to_string(),
200 y1: extract_attr(tag, "y1").to_string(),
201 x2: extract_attr(tag, "x2").to_string(),
202 y2: extract_attr(tag, "y2").to_string(),
203 });
204 } else {
205 last_stroke = "";
206 last_width = "";
207 }
208 }
209
210 search_from = skip_trailing_newlines(svg, abs_end);
211 }
212 if current_group.len() > 2 {
213 groups.push((
214 group_start,
215 group_end,
216 last_stroke.to_string(),
217 last_width.to_string(),
218 current_group,
219 ));
220 }
221
222 for (start, end, stroke, width, segs) in groups.into_iter().rev() {
224 let mut points = format!("{},{}", segs[0].x1, segs[0].y1);
225 for seg in &segs {
226 points.push_str(&format!(" {},{}", seg.x2, seg.y2));
227 }
228 let polyline = format!(
229 "<polyline points=\"{}\" stroke=\"{}\" stroke-width=\"{}\" fill=\"none\"/>",
230 points, stroke, width
231 );
232 svg.replace_range(start..skip_trailing_newlines(svg, end), &polyline);
233 }
234}
235
236fn rle_merge_rects(svg: &mut String) {
242 struct RectInfo {
243 x: i32,
244 y: i32,
245 width: i32,
246 height: i32,
247 fill: String,
248 has_ce_class: bool,
249 start: usize,
250 end: usize,
251 }
252
253 let mut rects: Vec<RectInfo> = Vec::new();
254 let mut search_from = 0;
255 while let Some(start) = svg[search_from..].find("<rect ") {
256 let abs_start = search_from + start;
257 let Some(end) = svg[abs_start..].find("/>") else {
258 break;
259 };
260 let abs_end = abs_start + end + 2;
261 let tag = &svg[abs_start..abs_end];
262
263 let fill = extract_attr(tag, "fill");
264 if fill.starts_with("rgb(") && !tag.contains("stroke") {
265 let w: i32 = extract_attr(tag, "width").parse().unwrap_or(0);
266 let h: i32 = extract_attr(tag, "height").parse().unwrap_or(0);
267 let x: i32 = extract_attr(tag, "x").parse().unwrap_or(0);
268 let y: i32 = extract_attr(tag, "y").parse().unwrap_or(0);
269
270 if w <= 100 && h <= 100 {
272 rects.push(RectInfo {
273 x,
274 y,
275 width: w,
276 height: h,
277 fill: fill.to_string(),
278 has_ce_class: tag.contains("class=\"ce\""),
279 start: abs_start,
280 end: abs_end,
281 });
282 }
283 }
284
285 search_from = abs_end;
286 }
287
288 let mut replacements: Vec<(usize, usize, String)> = Vec::new();
289 let mut i = 0;
290 while i < rects.len() {
291 let mut run_end = i + 1;
292 while run_end < rects.len()
293 && rects[run_end].y == rects[i].y
294 && rects[run_end].height == rects[i].height
295 && rects[run_end].fill == rects[i].fill
296 && rects[run_end].has_ce_class == rects[i].has_ce_class
297 && rects[run_end].x == rects[i].x + rects[i].width * (run_end - i) as i32
298 {
299 run_end += 1;
300 }
301
302 if run_end > i + 1 {
303 let merged_width = rects[i].width * (run_end - i) as i32;
304 let class_attr = if rects[i].has_ce_class {
305 " class=\"ce\""
306 } else {
307 ""
308 };
309 let merged = format!(
310 "<rect width=\"{}\" height=\"{}\" x=\"{}\" y=\"{}\" fill=\"{}\"{}/>",
311 merged_width, rects[i].height, rects[i].x, rects[i].y, rects[i].fill, class_attr
312 );
313 replacements.push((rects[i].start, rects[run_end - 1].end, merged));
314 }
315
316 i = run_end;
317 }
318
319 for (start, end, replacement) in replacements.into_iter().rev() {
320 svg.replace_range(start..skip_trailing_newlines(svg, end), &replacement);
321 }
322}
323
324fn skip_trailing_newlines(s: &str, pos: usize) -> usize {
326 let mut end = pos;
327 while end < s.len() && s.as_bytes().get(end).is_some_and(|&b| b == b'\n') {
328 end += 1;
329 }
330 end
331}
332
333fn extract_attr<'a>(tag: &'a str, attr: &str) -> &'a str {
335 let pattern = format!("{}=\"", attr);
336 if let Some(start) = tag.find(&pattern) {
337 let val_start = start + pattern.len();
338 if let Some(end) = tag[val_start..].find('"') {
339 return &tag[val_start..val_start + end];
340 }
341 }
342 ""
343}
344
345pub fn write_default_html_table(text_report: &str, w: &mut dyn Write) -> io::Result<()> {
354 let mut lines = text_report.lines();
355
356 write!(w, "<table>")?;
357
358 if let Some(header_line) = lines.next() {
360 let header = header_line.trim_start_matches('#');
362 let cols: Vec<&str> = header.split('\t').collect();
363
364 write!(w, "<thead>")?;
365 write!(w, "<tr>")?;
366 for col in &cols {
367 write!(w, "<th>")?;
368 write_escaped(w, col)?;
369 write!(w, "</th>")?;
370 }
371 write!(w, "</tr>")?;
372 write!(w, "</thead>")?;
373 }
374
375 write!(w, "<tbody>")?;
376 for line in lines {
377 if line.is_empty() {
378 continue;
379 }
380 let cells: Vec<&str> = line.split('\t').collect();
381 write!(w, "<tr>")?;
382 for cell in &cells {
383 write!(w, "<td>")?;
384 write_escaped(w, cell)?;
385 write!(w, "</td>")?;
386 }
387 write!(w, "</tr>")?;
388 }
389 write!(w, "</tbody>")?;
390 write!(w, "</table>")?;
391
392 Ok(())
393}
394
395pub fn write_escaped(w: &mut dyn Write, s: &str) -> io::Result<()> {
399 for ch in s.chars() {
400 match ch {
401 '&' => write!(w, "&")?,
402 '<' => write!(w, "<")?,
403 '>' => write!(w, ">")?,
404 _ => write!(w, "{}", ch)?,
405 }
406 }
407 Ok(())
408}
409
410pub fn format_java_date(dt: &chrono::DateTime<Local>) -> String {
415 dt.format("%a %e %b %Y").to_string().replace(" ", " ")
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn test_escape() {
426 let mut buf = Vec::new();
427 write_escaped(&mut buf, "A & B < C > D").unwrap();
428 assert_eq!(String::from_utf8(buf).unwrap(), "A & B < C > D");
429 }
430
431 #[test]
432 fn test_format_java_date() {
433 use chrono::TimeZone;
434 let dt = chrono::FixedOffset::east_opt(0)
435 .unwrap()
436 .with_ymd_and_hms(2026, 4, 5, 12, 0, 0)
437 .unwrap()
438 .with_timezone(&Local);
439 let formatted = format_java_date(&dt);
440 assert!(formatted.contains(" 5 "), "Got: {}", formatted);
442 }
443
444 #[test]
445 fn test_default_html_table() {
446 let text = "#Measure\tValue\nFilename\ttest.fastq\nTotal\t100\n";
447 let mut buf = Vec::new();
448 write_default_html_table(text, &mut buf).unwrap();
449 let html = String::from_utf8(buf).unwrap();
450 assert!(html.starts_with("<table>"));
451 assert!(html.contains("<th>Measure</th>"));
452 assert!(html.contains("<td>test.fastq</td>"));
453 assert!(html.ends_with("</table>"));
454 }
455}