use crate::error::MermaidError; use crate::theme::MermaidTheme; use std::collections::BTreeMap; /// Mermaid 11.12.2 gantt config defaults (from config.schema.yaml) const BAR_HEIGHT: f64 = 20.0; const BAR_GAP: f64 = 4.0; const TOP_PADDING: f64 = 50.0; const LEFT_PADDING: f64 = 75.0; const RIGHT_PADDING: f64 = 75.0; const GRID_LINE_START_PADDING: f64 = 35.0; const FONT_SIZE: f64 = 11.0; const SECTION_FONT_SIZE: f64 = 11.0; const TITLE_TOP_MARGIN: f64 = 25.0; const BOTTOM_AXIS_HEIGHT: f64 = 50.0; const RX: f64 = 3.0; const RY: f64 = 3.0; /// Default theme colors from Mermaid 11.12.2 theme-default.js const SECTION_BKG_COLOR: &str = "rgba(102,102,255,0.49)"; const ALT_SECTION_BKG_COLOR: &str = "white"; const TASK_BKG_COLOR: &str = "#8a90dd"; const TASK_BORDER_COLOR: &str = "#534fbc"; const TASK_TEXT_COLOR: &str = "white"; const TASK_TEXT_DARK_COLOR: &str = "black"; const GRID_COLOR: &str = "#333"; const TITLE_COLOR: &str = "#333"; const FONT_FAMILY: &str = "'trebuchet ms', verdana, arial, sans-serif"; pub fn render_gantt_diagram_to_svg( mermaid_source: &str, _theme: &MermaidTheme, ) -> Result { let chart = parse_gantt_diagram(mermaid_source)?; // Collect unique categories (section types) in order let mut categories: Vec = Vec::new(); for task in &chart.tasks { let cat = task.section.clone().unwrap_or_default(); if !categories.contains(&cat) { categories.push(cat); } } // Category heights (count of tasks per category) let mut category_heights: BTreeMap = BTreeMap::new(); for task in &chart.tasks { let cat = task.section.clone().unwrap_or_default(); *category_heights.entry(cat).or_insert(0) += 1; } let num_tasks = chart.tasks.len(); let gap = BAR_HEIGHT + BAR_GAP; let h = 2.0 * TOP_PADDING + num_tasks as f64 * gap; // Compute time domain let mut min_day = i32::MAX; let mut max_day = i32::MIN; for task in &chart.tasks { min_day = min_day.min(task.start_day); max_day = max_day.max(task.start_day + task.duration_days); } let w = 784.0_f64; let plot_width = w - LEFT_PADDING - RIGHT_PADDING; // Time scale: maps day offset to pixel x let span_days = (max_day - min_day).max(1) as f64; let px_per_day = plot_width / span_days; let mut svg = String::new(); svg.push_str(&format!( "" )); // Embedded "); Ok(svg) } #[derive(Debug, Clone)] struct GanttChart { title: Option, tasks: Vec, } #[derive(Debug, Clone)] struct GanttTask { section: Option, name: String, start_day: i32, duration_days: i32, } fn parse_gantt_diagram(input: &str) -> Result { let lines: Vec<&str> = input.lines().collect(); let mut i = 0_usize; while i < lines.len() { let line = lines[i].trim(); if line.is_empty() || line.starts_with("%%") { i += 1; continue; } if line.split_whitespace().next() == Some("gantt") { i += 1; break; } return Err(MermaidError::ParseError { line: i + 1, message: "Expected 'gantt' declaration".to_string(), }); } let mut title: Option = None; let mut current_section: Option = None; let mut tasks: Vec = Vec::new(); let mut tasks_by_id: BTreeMap = BTreeMap::new(); while i < lines.len() { let raw = lines[i]; let line = raw.trim(); let line_no = i + 1; i += 1; if line.is_empty() || line.starts_with("%%") { continue; } if let Some(rest) = line.strip_prefix("title ") { let t = rest.trim(); if !t.is_empty() { title = Some(t.to_string()); } continue; } if line.starts_with("dateFormat ") { continue; } if let Some(rest) = line.strip_prefix("section ") { let name = rest.trim(); current_section = if name.is_empty() { None } else { Some(name.to_string()) }; continue; } let Some((name_raw, spec_raw)) = line.split_once(':') else { return Err(MermaidError::ParseError { line: line_no, message: format!("Invalid gantt task line: {line}"), }); }; let name = name_raw.trim(); let spec_parts: Vec<&str> = spec_raw .split(',') .map(|p| p.trim()) .filter(|p| !p.is_empty()) .collect(); if spec_parts.len() < 3 { return Err(MermaidError::ParseError { line: line_no, message: format!("Invalid gantt task spec: {spec_raw}"), }); } let id = spec_parts[0].to_string(); let start_spec = spec_parts[1]; let duration_spec = spec_parts[2]; let duration_days = parse_duration_days(duration_spec).map_err(|message| MermaidError::ParseError { line: line_no, message, })?; let start_day = if let Some(after) = start_spec.strip_prefix("after ") { let ref_id = after.trim(); let Some((ref_start, ref_dur)) = tasks_by_id.get(ref_id).copied() else { return Err(MermaidError::ParseError { line: line_no, message: format!("Unknown gantt dependency id: {ref_id}"), }); }; ref_start + ref_dur } else { parse_ymd_to_day(start_spec).map_err(|message| MermaidError::ParseError { line: line_no, message, })? }; let task = GanttTask { section: current_section.clone(), name: name.to_string(), start_day, duration_days, }; tasks_by_id.insert(id, (start_day, duration_days)); tasks.push(task); } if tasks.is_empty() { return Err(MermaidError::ParseError { line: 1, message: "Gantt diagram requires at least one task".to_string(), }); } Ok(GanttChart { title, tasks }) } fn parse_duration_days(spec: &str) -> Result { let spec = spec.trim(); if spec.is_empty() { return Err("Empty duration".to_string()); } let (num_str, unit) = spec.split_at(spec.len().saturating_sub(1)); let n: i32 = num_str .trim() .parse() .map_err(|_| format!("Invalid duration: {spec}"))?; match unit { "d" | "D" => Ok(n), "w" | "W" => Ok(n * 7), _ => Err(format!("Unsupported duration unit: {spec}")), } } fn parse_ymd_to_day(s: &str) -> Result { let parts: Vec<&str> = s.trim().split('-').collect(); if parts.len() != 3 { return Err(format!("Invalid date: {s}")); } let y: i32 = parts[0].parse().map_err(|_| format!("Invalid year: {s}"))?; let m: i32 = parts[1] .parse() .map_err(|_| format!("Invalid month: {s}"))?; let d: i32 = parts[2].parse().map_err(|_| format!("Invalid day: {s}"))?; Ok(days_from_civil(y, m, d)) } /// Convert a day number back to (year, month, day). fn day_to_ymd(day_number: i32) -> (i32, i32, i32) { let z = day_number + 719468; let era = if z >= 0 { z } else { z - 146096 } / 146097; let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y }; (y, m, d) } fn day_to_ymd_str(day_number: i32) -> String { let (y, m, d) = day_to_ymd(day_number); format!("{y:04}-{m:02}-{d:02}") } fn days_from_civil(y: i32, m: i32, d: i32) -> i32 { let y = y - if m <= 2 { 1 } else { 0 }; let era = if y >= 0 { y } else { y - 399 } / 400; let yoe = y - era * 400; let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146097 + doe - 719468 } fn escape_xml(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") }