Skip to main content

fastqc_rust/report/
html.rs

1// HTML report generation
2// Corresponds to Report/HTMLReportArchive.java
3//
4// The Java implementation uses XMLStreamWriter which produces XML-style output:
5// - Self-closing tags: `<img .../>` (no space before /)
6// - Entity escaping: &amp; &lt; &gt; &quot;
7// - No pretty-printing / newlines between elements
8// We replicate that style here.
9
10use 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
18/// Generate the complete HTML report as a String.
19///
20/// Delegates to the selected template for the actual HTML structure.
21pub 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
32/// Write a chart image for a module that has one.
33///
34/// In Java, modules with charts call writeDefaultImage() which embeds
35/// ONLY the chart image in the HTML — no data table. The data table only appears
36/// in fastqc_data.txt. Modules without charts (BasicStats, OverRepresentedSeqs)
37/// call writeTable() which renders an HTML table instead.
38pub 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
59/// Write a chart as inline SVG for a module that has one.
60///
61/// Unlike `write_chart` which converts SVG→PNG→base64, this embeds the SVG
62/// directly in the HTML for crisper rendering on modern displays.
63/// The SVG is minified to reduce file size.
64pub 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
74/// Minify an SVG string for inline HTML embedding.
75///
76/// The SVG generator produces verbose output optimised for resvg PNG rendering.
77/// This function applies several size reductions for inline HTML display:
78/// - Strip XML declaration and DOCTYPE
79/// - Move repeated attributes (shape-rendering, font-family) to CSS classes
80/// - Shorten fill/stroke style attributes
81/// - Merge consecutive same-colour `<line>` segments into `<polyline>` elements
82/// - Run-length merge consecutive same-colour `<rect>` elements in heatmaps
83fn minify_svg(svg: &str) -> String {
84    let mut out = String::with_capacity(svg.len());
85
86    // Collect non-declaration lines and apply text replacements
87    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        // Inject CSS and max-width into the <svg> tag
94        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        // Collect <line> elements for merging into polylines later
107        if t.starts_with("<line ") {
108            out.push_str(t);
109            out.push('\n');
110            continue;
111        }
112
113        // Apply attribute shortening to other elements
114        let shortened = t
115            .replace(" shape-rendering=\"crispEdges\"", " class=\"ce\"")
116            .replace(
117                " font-family=\"'Liberation Sans', Arial, Helvetica, sans-serif\"",
118                "",
119            );
120        // Filled rects: style="fill:rgb(R,G,B);stroke:none" → fill="rgb(R,G,B)"
121        let shortened = shortened
122            .replace("style=\"fill:rgb(", "fill=\"rgb(")
123            .replace(");stroke:none\"", ")\"");
124        // Stroked rects: style="fill:none;stroke-width:1;stroke:rgb(R,G,B)" → attributes
125        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    // Post-process: merge lines into polylines and RLE-merge rects
134    merge_lines_to_polylines(&mut out);
135    rle_merge_rects(&mut out);
136    out
137}
138
139/// Merge consecutive `<line>` elements with the same stroke colour into `<polyline>` elements.
140///
141/// Data series in line graphs are drawn as many individual `<line>` segments.
142/// A polyline with N points is much more compact than N separate line elements.
143/// Gridlines (grey or black) are left as-is since they aren't contiguous series.
144fn 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    // (start_byte, end_byte, stroke, width, segments)
153    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    // Replace groups back-to-front to preserve offsets
223    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
236/// Run-length merge consecutive same-colour `<rect>` elements.
237///
238/// Heatmaps (like per-tile quality) draw thousands of small rects where many
239/// adjacent cells have the same colour. Merging runs of identical-colour rects
240/// on the same row into a single wider rect dramatically reduces element count.
241fn 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            // Skip large background rects
271            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
324/// Skip past trailing newline characters from a position in the string.
325fn 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
333/// Extract an XML attribute value from a tag string, returning a borrowed slice.
334fn 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
345/// Write an HTML table from tab-delimited text report data.
346///
347/// This is the default HTML output for modules that use `write_text_report` to
348/// produce a tab-delimited table. It parses the text report output and converts
349/// it to an HTML table matching Java's `writeXhtmlTable()` output.
350///
351/// The Java AbstractQCModule.writeXhtmlTable() writes
352/// `<table><thead><tr><th>...</th></tr></thead><tbody><tr><td>...</td></tr>...</tbody></table>`
353pub 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    // First line is the header (starts with #)
359    if let Some(header_line) = lines.next() {
360        // Header row starts with '#' in text report
361        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
395/// Escape special XML/HTML characters, matching Java XMLStreamWriter.writeCharacters().
396///
397/// XMLStreamWriter escapes &, <, > in character data.
398pub fn write_escaped(w: &mut dyn Write, s: &str) -> io::Result<()> {
399    for ch in s.chars() {
400        match ch {
401            '&' => write!(w, "&amp;")?,
402            '<' => write!(w, "&lt;")?,
403            '>' => write!(w, "&gt;")?,
404            _ => write!(w, "{}", ch)?,
405        }
406    }
407    Ok(())
408}
409
410/// Format a date matching Java's `SimpleDateFormat("EEE d MMM yyyy")`.
411///
412/// Java outputs e.g. "Sun 5 Apr 2026" — abbreviated weekday,
413/// unpadded day-of-month, abbreviated month, four-digit year.
414pub fn format_java_date(dt: &chrono::DateTime<Local>) -> String {
415    // chrono's %e gives space-padded day, but Java uses unpadded.
416    // We use %e and trim the leading space.
417    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 &amp; B &lt; C &gt; 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        // Day should not be zero-padded
441        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}