diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 3e0f4ffa..07c9c18c 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -415,7 +415,7 @@ fn apply_title_to_encoding( .and_then(|labels| labels.labels.get(primary)); if let Some(label) = explicit_label { - encoding["title"] = json!(label); + encoding["title"] = super::split_label_on_newlines(label); titled_families.insert(primary.to_string()); } else if let Some(orig) = original_name { // Use original column name as default title when available @@ -429,7 +429,7 @@ fn apply_title_to_encoding( // Variant without primary: allow first variant to claim title (for explicit labels) if let Some(ref labels) = spec.labels { if let Some(label) = labels.labels.get(primary) { - encoding["title"] = json!(label); + encoding["title"] = super::split_label_on_newlines(label); titled_families.insert(primary.to_string()); } } diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 2bf0c140..f0c70bf3 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -653,6 +653,34 @@ impl TextRenderer { Ok((result_df, run_lengths)) } + /// Split label values containing newlines into arrays of strings + /// + /// Uses the shared split_label_on_newlines function to ensure consistent + /// newline handling across all label types (text data, axis labels, titles, etc.) + fn split_label_newlines(values: &mut [Value]) -> Result<()> { + let label_col = naming::aesthetic_column("label"); + + for row in values.iter_mut() { + // Get the object, skip if not an object + let Some(obj) = row.as_object_mut() else { + continue; + }; + + // Get the label value, skip if not present + let Some(label_value) = obj.get(&label_col) else { + continue; + }; + // Get the string value, skip if not a string + let Some(label_str) = label_value.as_str() else { + continue; + }; + + // Use shared function for consistent newline splitting + obj.insert(label_col.clone(), super::split_label_on_newlines(label_str)); + } + Ok(()) + } + /// Convert typeface to Vega-Lite font value /// Prefers literal over column value fn convert_typeface( @@ -1032,12 +1060,15 @@ impl GeomRenderer for TextRenderer { // Slice the contiguous run from the DataFrame (more efficient than boolean masking) let sliced = df.slice(position as i64, length); - let values = if binned_columns.is_empty() { + let mut values = if binned_columns.is_empty() { dataframe_to_values(&sliced)? } else { dataframe_to_values_with_bins(&sliced, binned_columns)? }; + // Post-process label values to split on newlines + Self::split_label_newlines(&mut values)?; + components.insert(suffix, values); position += length; } @@ -2784,6 +2815,81 @@ mod tests { assert!(labels.contains(&"$21.00")); } + #[test] + fn test_text_label_newline_splitting() { + use crate::execute; + use crate::reader::DuckDBReader; + use crate::writer::vegalite::VegaLiteWriter; + use crate::writer::Writer; + + // Test that labels containing newlines are split into arrays + + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + // Query with labels containing newlines in DRAW and PLACE + let query = r#" + SELECT + n::INTEGER as x, + n::INTEGER as y, + CASE + WHEN n = 0 THEN 'First Line\nSecond Line' + WHEN n = 1 THEN 'Single Line' + ELSE 'Line 1\nLine 2\nLine 3' + END as label + FROM generate_series(0, 2) as t(n) + VISUALISE x, y, label + DRAW text + PLACE text SETTING x => 5, y => 15, label => 'Annotation\nWith Newline' + "#; + + let prepared = execute::prepare_data_with_reader(query, &reader).unwrap(); + let spec = &prepared.specs[0]; + + let writer = VegaLiteWriter::new(); + let json_str = writer.write(spec, &prepared.data).unwrap(); + let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + let data_values = vl_spec["data"]["values"].as_array().unwrap(); + let label_col = crate::naming::aesthetic_column("label"); + + // Check first label (contains newline - should be array) + let label_0 = &data_values[0][&label_col]; + assert!(label_0.is_array(), "Label with newline should be an array"); + let lines_0 = label_0.as_array().unwrap(); + assert_eq!(lines_0.len(), 2); + assert_eq!(lines_0[0].as_str().unwrap(), "First Line"); + assert_eq!(lines_0[1].as_str().unwrap(), "Second Line"); + + // Check second label (no newline - should be string) + let label_1 = &data_values[1][&label_col]; + assert!( + label_1.is_string(), + "Label without newline should be a string" + ); + assert_eq!(label_1.as_str().unwrap(), "Single Line"); + + // Check third label (multiple newlines - should be array with 3 elements) + let label_2 = &data_values[2][&label_col]; + assert!(label_2.is_array(), "Label with newlines should be an array"); + let lines_2 = label_2.as_array().unwrap(); + assert_eq!(lines_2.len(), 3); + assert_eq!(lines_2[0].as_str().unwrap(), "Line 1"); + assert_eq!(lines_2[1].as_str().unwrap(), "Line 2"); + assert_eq!(lines_2[2].as_str().unwrap(), "Line 3"); + + // Check PLACE annotation layer (index 3, after the 3 DRAW data rows) + assert!(data_values.len() > 3, "Should have annotation data"); + let annotation_label = &data_values[3][&label_col]; + assert!( + annotation_label.is_array(), + "Annotation label with newline should be an array" + ); + let annotation_lines = annotation_label.as_array().unwrap(); + assert_eq!(annotation_lines.len(), 2); + assert_eq!(annotation_lines[0].as_str().unwrap(), "Annotation"); + assert_eq!(annotation_lines[1].as_str().unwrap(), "With Newline"); + } + #[test] fn test_text_setting_fontweight() { use crate::execute; diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index ac65e9c3..405da449 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -49,6 +49,24 @@ const POINTS_TO_PIXELS: f64 = 96.0 / 72.0; /// So: area_px^2 = pi * (r_pt * POINTS_TO_PIXELS)^2 = pi * r_pt^2 * (96/72)^2 const POINTS_TO_AREA: f64 = std::f64::consts::PI * POINTS_TO_PIXELS * POINTS_TO_PIXELS; +/// Split a label string on newlines and return appropriate JSON value +/// +/// Returns a JSON array if the string contains multiple lines, or a JSON string +/// if it's a single line. Handles both actual newlines (from database columns, +/// CHAR(10), imported data) and literal \\n (from SQL string literals). +fn split_label_on_newlines(label: &str) -> Value { + // Normalize literal \\n to actual \n, then split on any newline type + let normalized = label.replace("\\n", "\n"); + let lines: Vec<&str> = normalized.lines().collect(); + + // Return array if multiple lines, string if single line + if lines.len() > 1 { + json!(lines) + } else { + json!(label) + } +} + /// Result of preparing layer data for rendering /// /// Contains the datasets, renderers, and prepared data needed to build Vega-Lite layers. @@ -1097,13 +1115,20 @@ impl Writer for VegaLiteWriter { match (title, subtitle) { (Some(t), Some(st)) => { // Vega-Lite uses an object for title + subtitle - vl_spec["title"] = json!({"text": t, "subtitle": st}); + // Split both title and subtitle on newlines + vl_spec["title"] = json!({ + "text": split_label_on_newlines(t), + "subtitle": split_label_on_newlines(st) + }); } (Some(t), None) => { - vl_spec["title"] = json!(t); + vl_spec["title"] = split_label_on_newlines(t); } (None, Some(st)) => { - vl_spec["title"] = json!({"text": "", "subtitle": st}); + vl_spec["title"] = json!({ + "text": "", + "subtitle": split_label_on_newlines(st) + }); } (None, None) => {} } @@ -1558,6 +1583,94 @@ mod tests { assert_eq!(vl_spec["layer"][0]["mark"]["clip"], true); } + #[test] + fn test_labels_newline_splitting() { + use crate::execute; + use crate::reader::DuckDBReader; + + // Test that LABEL clause values with newlines are split into arrays + + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + let query = r#" + SELECT + n::INTEGER as x, + n::INTEGER as y, + CASE WHEN n % 2 = 0 THEN 'Group A' ELSE 'Group B' END as category + FROM generate_series(0, 2) as t(n) + VISUALISE x, y, category AS stroke + DRAW point + LABEL + title => 'Multi-line\nChart Title', + subtitle => 'Line 1\nLine 2\nLine 3', + x => 'X Axis\nWith Newline', + y => 'Single Line', + stroke => 'Category\nMulti-line Legend' + "#; + + let prepared = execute::prepare_data_with_reader(query, &reader).unwrap(); + let spec = &prepared.specs[0]; + + let writer = VegaLiteWriter::new(); + let json_str = writer.write(spec, &prepared.data).unwrap(); + let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); + + // Check title (should be object with text and subtitle) + assert!(vl_spec["title"].is_object(), "Title should be an object"); + let title_obj = vl_spec["title"].as_object().unwrap(); + + // Check title text (multi-line, should be array) + assert!( + title_obj["text"].is_array(), + "Title with newline should be an array" + ); + let title_lines = title_obj["text"].as_array().unwrap(); + assert_eq!(title_lines.len(), 2); + assert_eq!(title_lines[0].as_str().unwrap(), "Multi-line"); + assert_eq!(title_lines[1].as_str().unwrap(), "Chart Title"); + + // Check subtitle (multi-line, should be array) + assert!( + title_obj["subtitle"].is_array(), + "Subtitle with newlines should be an array" + ); + let subtitle_lines = title_obj["subtitle"].as_array().unwrap(); + assert_eq!(subtitle_lines.len(), 3); + assert_eq!(subtitle_lines[0].as_str().unwrap(), "Line 1"); + assert_eq!(subtitle_lines[1].as_str().unwrap(), "Line 2"); + assert_eq!(subtitle_lines[2].as_str().unwrap(), "Line 3"); + + // Check x axis label (multi-line, should be array) + let x_encoding = &vl_spec["layer"][0]["encoding"]["x"]; + assert!( + x_encoding["title"].is_array(), + "X axis label with newline should be an array" + ); + let x_label_lines = x_encoding["title"].as_array().unwrap(); + assert_eq!(x_label_lines.len(), 2); + assert_eq!(x_label_lines[0].as_str().unwrap(), "X Axis"); + assert_eq!(x_label_lines[1].as_str().unwrap(), "With Newline"); + + // Check y axis label (single line, should be string) + let y_encoding = &vl_spec["layer"][0]["encoding"]["y"]; + assert!( + y_encoding["title"].is_string(), + "Y axis label without newline should be a string" + ); + assert_eq!(y_encoding["title"].as_str().unwrap(), "Single Line"); + + // Check stroke legend label (multi-line, should be array) + let stroke_encoding = &vl_spec["layer"][0]["encoding"]["stroke"]; + assert!( + stroke_encoding["title"].is_array(), + "Stroke legend title with newline should be an array" + ); + let stroke_label_lines = stroke_encoding["title"].as_array().unwrap(); + assert_eq!(stroke_label_lines.len(), 2); + assert_eq!(stroke_label_lines[0].as_str().unwrap(), "Category"); + assert_eq!(stroke_label_lines[1].as_str().unwrap(), "Multi-line Legend"); + } + #[test] fn test_fontsize_linear_scaling() { use crate::plot::{ArrayElement, OutputRange, Scale, ScaleType};