Skip to main content

fastqc_rust/report/charts/
mod.rs

1pub mod line_graph;
2pub mod quality_boxplot;
3pub mod tile_graph;
4
5/// A simple RGB color struct used across all chart types.
6#[derive(Debug, Clone, Copy)]
7pub struct ChartColor {
8    pub r: u8,
9    pub g: u8,
10    pub b: u8,
11}
12
13impl ChartColor {
14    pub const fn new(r: u8, g: u8, b: u8) -> Self {
15        ChartColor { r, g, b }
16    }
17
18    pub fn to_rgb_string(&self) -> String {
19        format!("rgb({},{},{})", self.r, self.g, self.b)
20    }
21}
22
23// Default chart dimensions match Java's JPanel.getPreferredSize() = 800x600
24pub const CHART_WIDTH: f64 = 800.0;
25pub const CHART_HEIGHT: f64 = 600.0;
26
27// Tol colorblind-safe palette from LineGraph.java
28// Note: Java FastQC updated these colours from the original bright palette
29// to the Tol scheme at https://davidmathlogic.com/colorblind/
30pub const LINE_COLOURS: [ChartColor; 8] = [
31    ChartColor::new(136, 34, 85),   // Purple-red
32    ChartColor::new(51, 34, 136),   // Indigo
33    ChartColor::new(17, 119, 51),   // Green
34    ChartColor::new(221, 204, 119), // Yellow-green
35    ChartColor::new(68, 170, 153),  // Teal
36    ChartColor::new(170, 68, 153),  // Magenta
37    ChartColor::new(204, 102, 119), // Pink
38    ChartColor::new(136, 204, 238), // Light blue
39];
40
41// Java uses Font("Default", Font.PLAIN, 12) for all chart text.
42// The SVGGenerator writes font-size="12" but Java AWT renders 12pt at screen DPI
43// (typically 96dpi) where it appears slightly larger than SVG's 12px.
44const FONT_SIZE: f64 = 12.0;
45/// Bold text in Java AWT is approximately 13% wider than plain text.
46/// Used when measuring text that will be rendered with font-weight="bold".
47pub const BOLD_WIDTH_SCALE: f64 = 1.13;
48// Java uses SansSerif which maps to Arial/Helvetica. We specify Liberation Sans
49// first (bundled, metric-compatible with Arial) with web-safe fallbacks.
50const FONT_FAMILY: &str = "'Liberation Sans', Arial, Helvetica, sans-serif";
51
52/// Approximate the width of a string in pixels at 12pt Arial.
53/// Java uses FontMetrics.stringWidth() which measures actual glyph widths.
54/// We approximate with 7px per character (roughly correct for 12pt Arial),
55/// which gives close-enough layout to match Java output visually.
56pub fn approx_text_width(s: &str) -> f64 {
57    // Approximate Java's FontMetrics.stringWidth() for Arial 12pt.
58    // Digits and uppercase are ~7px, lowercase ~5.5px, spaces ~3px.
59    // This produces correct overlap prevention for axis labels (mostly digits)
60    // AND correct centering for titles (mixed case text).
61    s.chars()
62        .map(|c| match c {
63            ' ' => 3.4,
64            '.' | ',' | ':' | ';' | '!' | 'i' | 'l' | '|' | '(' | ')' => 3.5,
65            'm' | 'w' | 'M' | 'W' => 9.0,
66            'A'..='Z' | '0'..='9' | '%' | '+' | '>' | '#' => 8.2,
67            _ => 5.7, // lowercase and other chars
68        })
69        .sum()
70}
71
72/// Generate the SVG header.
73pub fn svg_header(width: f64, height: f64) -> String {
74    // Match the SVG header format from SVGGenerator.java
75    format!(
76        "<?xml version=\"1.0\" standalone=\"no\"?>\n\
77         <!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n\
78         <svg width=\"{}\" height=\"{}\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\">\n",
79        width as i32, height as i32
80    )
81}
82
83/// Generate the SVG footer.
84pub fn svg_footer() -> &'static str {
85    "</svg>\n"
86}
87
88/// Escape text for safe embedding in XML (SVG, XSL-FO, etc.).
89pub fn xml_escape(s: &str) -> String {
90    s.replace('&', "&amp;")
91        .replace('<', "&lt;")
92        .replace('>', "&gt;")
93        .replace('"', "&quot;")
94}
95
96/// Emit an SVG text element at the default label font size.
97pub fn svg_text(x: f64, y: f64, text: &str, color: &ChartColor, bold: bool) -> String {
98    svg_text_sized(x, y, text, color, bold, FONT_SIZE)
99}
100
101/// Emit an SVG text element at a specific font size.
102fn svg_text_sized(x: f64, y: f64, text: &str, color: &ChartColor, bold: bool, size: f64) -> String {
103    let weight = if bold { " font-weight=\"bold\"" } else { "" };
104    format!(
105        "<text x=\"{}\" y=\"{}\" fill=\"{}\" font-family=\"{}\" font-size=\"{}\"{}>{}</text>\n",
106        x as i32,
107        y as i32,
108        color.to_rgb_string(),
109        FONT_FAMILY,
110        size as i32,
111        weight,
112        xml_escape(text)
113    )
114}
115
116/// Strip `shape-rendering="crispEdges"` from SVG output.
117///
118/// crispEdges is included in the SVG so that resvg renders pixel-sharp lines
119/// and rectangles in PNGs, but it is stripped from saved SVG files to minimise
120/// the diff from upstream Java output (which doesn't include it).
121pub fn strip_crisp_edges(svg: &str) -> String {
122    svg.replace(" shape-rendering=\"crispEdges\"", "")
123}
124
125/// Emit an SVG line element.
126pub fn svg_line(
127    x1: f64,
128    y1: f64,
129    x2: f64,
130    y2: f64,
131    color: &ChartColor,
132    stroke_width: f64,
133) -> String {
134    format!(
135        "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"{}\" stroke-width=\"{}\" shape-rendering=\"crispEdges\"/>\n",
136        x1 as i32,
137        y1 as i32,
138        x2 as i32,
139        y2 as i32,
140        color.to_rgb_string(),
141        stroke_width as i32
142    )
143}
144
145/// Emit an SVG filled rectangle.
146pub fn svg_rect_filled(x: f64, y: f64, width: f64, height: f64, color: &ChartColor) -> String {
147    format!(
148        "<rect width=\"{}\" height=\"{}\" x=\"{}\" y=\"{}\" style=\"fill:{};stroke:none\" shape-rendering=\"crispEdges\"/>\n",
149        width as i32,
150        height as i32,
151        x as i32,
152        y as i32,
153        color.to_rgb_string()
154    )
155}
156
157/// Emit an SVG stroked rectangle (no fill).
158pub fn svg_rect_stroked(x: f64, y: f64, width: f64, height: f64, color: &ChartColor) -> String {
159    format!(
160        "<rect width=\"{}\" height=\"{}\" x=\"{}\" y=\"{}\" rx=\"0\" ry=\"0\" style=\"fill:none;stroke-width:1;stroke:{}\" shape-rendering=\"crispEdges\"/>\n",
161        width as i32,
162        height as i32,
163        x as i32,
164        y as i32,
165        color.to_rgb_string()
166    )
167}
168
169/// Replicates findOptimalYInterval() from LineGraph.java.
170/// Finds a nice round interval so there are at most 10 gridlines.
171pub fn find_optimal_y_interval(max: f64) -> f64 {
172    let mut base = 1.0_f64;
173    let divisions = [1.0, 2.0, 2.5, 5.0];
174
175    loop {
176        for &d in &divisions {
177            let tester = base * d;
178            if max / tester <= 10.0 {
179                return tester;
180            }
181        }
182        base *= 10.0;
183    }
184}
185
186/// Format a Y-axis label, stripping trailing ".0" to match Java's behaviour.
187/// Java uses `(""+i).replaceAll(".0$", "")`.
188pub fn format_y_label(value: f64) -> String {
189    let s = format!("{}", value);
190    if s.ends_with(".0") {
191        s[..s.len() - 2].to_string()
192    } else {
193        s
194    }
195}
196
197/// Render a bold, centered title in the plot area.
198/// Title is bold and centered between x_offset and the right edge (width - 10).
199pub fn render_centered_title(svg: &mut String, title: &str, x_offset: f64, width: f64) {
200    let black = ChartColor::new(0, 0, 0);
201    let title_w = approx_text_width(title) * BOLD_WIDTH_SCALE;
202    let plot_area_center = x_offset + (width - x_offset - 10.0) / 2.0;
203    let title_x = plot_area_center - title_w / 2.0;
204    svg.push_str(&svg_text(title_x, 30.0, title, &black, true));
205}
206
207/// Shared layout state for charts with numeric Y-axis and categorical X-axis.
208///
209/// Encapsulates the common chart setup pattern used by LineGraph.java
210/// and QualityBoxPlot.java: computing xOffset from Y-axis label widths, y_start
211/// from the Y interval, and the get_y() mapping function.
212pub struct ChartLayout {
213    pub width: f64,
214    pub height: f64,
215    pub x_offset: f64,
216    pub y_start: f64,
217    pub y_interval: f64,
218    pub min_y: f64,
219    pub max_y: f64,
220}
221
222impl ChartLayout {
223    /// Create a chart layout by computing x_offset from Y-axis label widths.
224    pub fn new(min_y: f64, max_y: f64, y_interval: f64) -> Self {
225        let width = CHART_WIDTH;
226        let height = CHART_HEIGHT;
227
228        // yStart calculation matches Java
229        let y_start = if min_y % y_interval == 0.0 {
230            min_y
231        } else {
232            y_interval * ((min_y / y_interval) as i64 + 1) as f64
233        };
234
235        // Calculate xOffset from widest Y-axis label
236        let mut x_offset: f64 = 0.0;
237        let mut y_val = y_start;
238        while y_val <= max_y + y_interval * 0.001 {
239            let label = format_y_label(y_val);
240            let w = approx_text_width(&label);
241            if w > x_offset {
242                x_offset = w;
243            }
244            y_val += y_interval;
245        }
246        // Add 5px breathing space, then truncate to int to match Java's `int xOffset` // JAVA COMPAT
247        x_offset = (x_offset + 5.0).trunc();
248
249        ChartLayout {
250            width,
251            height,
252            x_offset,
253            y_start,
254            y_interval,
255            min_y,
256            max_y,
257        }
258    }
259
260    /// getY() maps a data value to a pixel Y coordinate.
261    /// Matches Java: `(getHeight()-40) - (int)(((getHeight()-80)/(maxY-minY))*(y-minY))`
262    /// The inner multiplication result is truncated to int before subtraction. // JAVA COMPAT
263    pub fn get_y(&self, value: f64) -> f64 {
264        let plot_height = self.height - 80.0;
265        let y_range = self.max_y - self.min_y;
266        // Java: (int)(NaN) == 0, so NaN values map to height-40 (bottom of plot). // JAVA COMPAT
267        let scaled = (plot_height / y_range) * (value - self.min_y);
268        (self.height - 40.0) - if scaled.is_nan() { 0.0 } else { scaled.trunc() }
269    }
270
271    /// Calculate the width of each data column in the plot area.
272    /// Uses floor() to match Java's integer division truncation:
273    /// `int baseWidth = (getWidth()-(xOffset+10))/xLabels.length`
274    pub fn base_width(&self, num_points: usize) -> f64 {
275        ((self.width - self.x_offset - 10.0) / num_points.max(1) as f64)
276            .floor()
277            .max(1.0)
278    }
279
280    /// Half of base_width, truncated to match Java's `baseWidth/2` int division. // JAVA COMPAT
281    pub fn half_base_width(&self, num_points: usize) -> f64 {
282        (self.base_width(num_points) / 2.0).trunc()
283    }
284
285    /// Render gray + white background rectangles matching Java's JPanel default paint.
286    pub fn render_background(&self, svg: &mut String) {
287        // Gray background then white overlay — matches Java's JPanel default
288        // paint which fills with the panel background (238,238,238) first
289        svg.push_str(&svg_rect_filled(
290            0.0,
291            0.0,
292            self.width,
293            self.height,
294            &ChartColor::new(238, 238, 238),
295        ));
296        svg.push_str(&svg_rect_filled(
297            0.0,
298            0.0,
299            self.width,
300            self.height,
301            &ChartColor::new(255, 255, 255),
302        ));
303    }
304
305    /// Render Y-axis labels at each tick interval.
306    pub fn render_y_labels(&self, svg: &mut String) {
307        let black = ChartColor::new(0, 0, 0);
308        let mut y_val = self.y_start;
309        while y_val <= self.max_y + self.y_interval * 0.001 {
310            let label = format_y_label(y_val);
311            let y_pos = self.get_y(y_val);
312            // Y-axis labels are left-aligned at x=2, matching Java's
313            // g.drawString(label, 2, getY(i)+(ascent/2))
314            let label_x = 2.0;
315            // Vertically center on gridline by adding ascent/2 (FONT_SIZE/2 ~ 6px)
316            svg.push_str(&svg_text(
317                label_x,
318                y_pos + FONT_SIZE / 2.0,
319                &label,
320                &black,
321                false,
322            ));
323            y_val += self.y_interval;
324        }
325    }
326
327    /// Render the X and Y axis lines.
328    pub fn render_axes(&self, svg: &mut String) {
329        let black = ChartColor::new(0, 0, 0);
330        svg.push_str(&svg_line(
331            self.x_offset,
332            self.height - 40.0,
333            self.width - 10.0,
334            self.height - 40.0,
335            &black,
336            1.0,
337        ));
338        svg.push_str(&svg_line(
339            self.x_offset,
340            self.height - 40.0,
341            self.x_offset,
342            40.0,
343            &black,
344            1.0,
345        ));
346    }
347
348    /// Render the X-axis label centered below the axis.
349    pub fn render_x_axis_label(&self, svg: &mut String, label: &str) {
350        let black = ChartColor::new(0, 0, 0);
351        let x_label_w = approx_text_width(label);
352        let x_label_x = self.width / 2.0 - x_label_w / 2.0;
353        svg.push_str(&svg_text(
354            x_label_x,
355            self.height - 5.0,
356            label,
357            &black,
358            false,
359        ));
360    }
361
362    /// Render a single X-axis category label at position `i`, if it doesn't overlap
363    /// the previous label. Returns the updated `last_x_label_end` value.
364    pub fn render_x_category_label_at(
365        &self,
366        svg: &mut String,
367        label: &str,
368        i: usize,
369        base_width: f64,
370        last_x_label_end: f64,
371    ) -> f64 {
372        let half_bw = (base_width / 2.0).trunc();
373        let label_w = approx_text_width(label).trunc();
374        let label_x = half_bw + self.x_offset + (base_width * i as f64) - (label_w / 2.0).trunc();
375        if label_x > last_x_label_end {
376            let black = ChartColor::new(0, 0, 0);
377            svg.push_str(&svg_text(label_x, self.height - 25.0, label, &black, false));
378            label_x + label_w + 5.0
379        } else {
380            last_x_label_end
381        }
382    }
383
384    /// Render horizontal gridlines at each Y-axis tick.
385    pub fn render_gridlines(&self, svg: &mut String) {
386        let grid_color = ChartColor::new(180, 180, 180);
387        let mut y_val = self.y_start;
388        while y_val <= self.max_y + self.y_interval * 0.001 {
389            let y_pos = self.get_y(y_val);
390            svg.push_str(&svg_line(
391                self.x_offset,
392                y_pos,
393                self.width - 10.0,
394                y_pos,
395                &grid_color,
396                1.0,
397            ));
398            y_val += self.y_interval;
399        }
400    }
401}
402
403/// Convert raw PNG bytes to a `data:image/png;base64,...` URI.
404/// Matches ImageToBase64.imageToBase64() which produces
405/// "data:image/png;base64,..." encoding for BufferedImage rendered charts.
406pub fn png_to_data_uri(png_bytes: &[u8]) -> String {
407    use base64::engine::general_purpose::STANDARD as BASE64;
408    use base64::Engine;
409    format!("data:image/png;base64,{}", BASE64.encode(png_bytes))
410}
411
412/// Convert an SVG string to PNG bytes.
413///
414/// In Java, writeDefaultImage() renders the Swing JPanel to a
415/// BufferedImage at the specified dimensions, then encodes via ImageIO.write("PNG").
416/// We replicate this by parsing the SVG with resvg and rasterizing via tiny-skia.
417// Bundled fonts — no system font dependency.
418// Liberation Sans is metric-compatible with Arial (SIL Open Font License).
419const FONT_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/LiberationSans-Regular.ttf");
420const FONT_BOLD: &[u8] = include_bytes!("../../../assets/fonts/LiberationSans-Bold.ttf");
421
422pub fn svg_to_png(svg: &str, width: u32, height: u32) -> Result<Vec<u8>, String> {
423    use resvg::usvg;
424    use tiny_skia::Pixmap;
425
426    // Load bundled fonts — no system font dependency
427    let mut fontdb = usvg::fontdb::Database::new();
428    fontdb.load_font_data(FONT_REGULAR.to_vec());
429    fontdb.load_font_data(FONT_BOLD.to_vec());
430
431    let options = usvg::Options {
432        fontdb: std::sync::Arc::new(fontdb),
433        ..Default::default()
434    };
435
436    let tree =
437        usvg::Tree::from_str(svg, &options).map_err(|e| format!("Failed to parse SVG: {}", e))?;
438
439    // Create pixel buffer at target dimensions
440    let mut pixmap =
441        Pixmap::new(width, height).ok_or_else(|| "Failed to create pixel buffer".to_string())?;
442
443    pixmap.fill(tiny_skia::Color::WHITE);
444
445    // Render at identity transform (1:1 pixel mapping) since our SVG dimensions
446    // match the target pixmap exactly. Any non-identity scale causes antialiased
447    // sub-pixel interpolation that blurs lines and rectangles.
448    resvg::render(
449        &tree,
450        tiny_skia::Transform::identity(),
451        &mut pixmap.as_mut(),
452    );
453
454    // Encode to PNG
455    let mut png_buf = Vec::new();
456    {
457        let mut encoder = png::Encoder::new(std::io::Cursor::new(&mut png_buf), width, height);
458        encoder.set_color(png::ColorType::Rgba);
459        encoder.set_depth(png::BitDepth::Eight);
460        let mut writer = encoder
461            .write_header()
462            .map_err(|e| format!("PNG header error: {}", e))?;
463        writer
464            .write_image_data(pixmap.data())
465            .map_err(|e| format!("PNG write error: {}", e))?;
466    }
467
468    Ok(png_buf)
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn test_find_optimal_y_interval() {
477        // Verify interval calculation matches Java's findOptimalYInterval
478        assert_eq!(find_optimal_y_interval(10.0), 1.0);
479        assert_eq!(find_optimal_y_interval(20.0), 2.0);
480        assert_eq!(find_optimal_y_interval(25.0), 2.5);
481        assert_eq!(find_optimal_y_interval(50.0), 5.0);
482        assert_eq!(find_optimal_y_interval(100.0), 10.0);
483        assert_eq!(find_optimal_y_interval(200.0), 20.0);
484    }
485
486    #[test]
487    fn test_format_y_label() {
488        // Trailing .0 should be stripped
489        assert_eq!(format_y_label(10.0), "10");
490        assert_eq!(format_y_label(2.5), "2.5");
491        assert_eq!(format_y_label(0.0), "0");
492    }
493
494    #[test]
495    fn test_chart_color_rgb_string() {
496        let c = ChartColor::new(255, 128, 0);
497        assert_eq!(c.to_rgb_string(), "rgb(255,128,0)");
498    }
499
500    #[test]
501    fn test_line_graph_renders_valid_svg() {
502        use crate::report::charts::line_graph::{render_line_graph, LineGraphData};
503
504        let svg = render_line_graph(&LineGraphData {
505            data: vec![vec![1.0, 5.0, 3.0]],
506            min_y: 0.0,
507            max_y: 10.0,
508            x_label: "X".to_string(),
509            series_names: vec!["Series 1".to_string()],
510            x_categories: vec!["A".to_string(), "B".to_string(), "C".to_string()],
511            title: "Test Graph".to_string(),
512        });
513
514        assert!(svg.starts_with("<?xml version"));
515        assert!(svg.contains("<svg "));
516        assert!(svg.contains("</svg>"));
517        assert!(svg.contains("Test Graph"));
518        assert!(svg.contains("Series 1"));
519    }
520
521    #[test]
522    fn test_quality_boxplot_renders_valid_svg() {
523        use crate::report::charts::quality_boxplot::{render_quality_boxplot, QualityBoxPlotData};
524
525        let svg = render_quality_boxplot(&QualityBoxPlotData {
526            means: vec![30.0, 28.0],
527            medians: vec![31.0, 29.0],
528            lower_quartile: vec![25.0, 24.0],
529            upper_quartile: vec![35.0, 33.0],
530            lowest: vec![20.0, 18.0],
531            highest: vec![38.0, 36.0],
532            min_y: 0.0,
533            max_y: 40.0,
534            y_interval: 2.0,
535            x_labels: vec!["1".to_string(), "2".to_string()],
536            title: "Test Quality".to_string(),
537        });
538
539        assert!(svg.starts_with("<?xml version"));
540        assert!(svg.contains("</svg>"));
541        assert!(svg.contains("Test Quality"));
542        // Check for quality zone colors
543        assert!(svg.contains("rgb(195,230,195)")); // GOOD color
544        assert!(svg.contains("rgb(240,240,0)")); // BOX_FILL color
545    }
546
547    #[test]
548    fn test_tile_graph_renders_valid_svg() {
549        use crate::report::charts::tile_graph::{render_tile_graph, TileGraphData};
550
551        let svg = render_tile_graph(&TileGraphData {
552            x_labels: vec!["1".to_string(), "2".to_string()],
553            tiles: vec![1101, 1102],
554            tile_base_means: vec![vec![0.5, -0.3], vec![-1.0, 0.2]],
555            color_scale_max: 5.0,
556        });
557
558        assert!(svg.starts_with("<?xml version"));
559        assert!(svg.contains("</svg>"));
560        assert!(svg.contains("Quality per tile"));
561    }
562}