From 47761beca8827fa4b75bbbf5cfb97a44fbafbc43 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 13 Apr 2026 12:30:42 +0200 Subject: [PATCH 1/3] Support multi-line text labels by splitting on newlines TextRenderer now detects newlines in label values and converts them to arrays of strings for Vega-Lite rendering. Handles both actual newline characters (from database columns, CHAR(10), imported data) and literal \n from SQL string literals. Uses platform-agnostic .lines() method to handle \n, \r\n, and \r. Single-line labels remain unchanged. Co-Authored-By: Claude Sonnet 4.5 --- src/writer/vegalite/layer.rs | 117 ++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 2bf0c140..8137eded 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -653,6 +653,43 @@ impl TextRenderer { Ok((result_df, run_lengths)) } + /// Split label values containing newlines into arrays of strings + fn split_label_newlines(values: &mut Vec) -> 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; + }; + + // Normalize literal \\n to actual \n, then split on any newline type + // - Actual \n: from database columns, CHAR(10), or imported data + // - Literal \\n: from SQL string literals like 'Line 1\nLine 2' + // .lines() handles all platform newline types (\n, \r\n, \r) + let normalized = label_str.replace("\\n", "\n"); + let lines: Vec<&str> = normalized.lines().collect(); + + // Only convert to array if we have multiple lines + if lines.len() <= 1 { + continue; // Single line or empty, leave as-is + } + + obj.insert(label_col.clone(), json!(lines)); + } + Ok(()) + } + /// Convert typeface to Vega-Lite font value /// Prefers literal over column value fn convert_typeface( @@ -1032,12 +1069,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 +2824,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; From bb9b86a62f2058f804a44caf742db6044158b310 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 13 Apr 2026 12:42:08 +0200 Subject: [PATCH 2/3] clippy's wish is my command --- src/writer/vegalite/layer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 8137eded..39dd21f5 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -654,7 +654,7 @@ impl TextRenderer { } /// Split label values containing newlines into arrays of strings - fn split_label_newlines(values: &mut Vec) -> Result<()> { + fn split_label_newlines(values: &mut [Value]) -> Result<()> { let label_col = naming::aesthetic_column("label"); for row in values.iter_mut() { From e38da6174f2fb1b5de7f27a32a2e4d114e3a7ef2 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 13 Apr 2026 13:22:12 +0200 Subject: [PATCH 3/3] Apply newline splitting to all label types (LABEL clause) Extended newline splitting from text geom labels to all label types: - Chart titles and subtitles - Axis labels (x, y, and all position aesthetics) - Legend titles (color, stroke, and all non-position aesthetics) Created shared split_label_on_newlines() function used by: 1. Chart title/subtitle rendering (mod.rs) 2. Axis label rendering (encoding.rs) 3. Legend title rendering (encoding.rs) 4. Text geom label data (layer.rs TextRenderer) This ensures consistent newline handling across all label contexts. Users can now use \n in any LABEL clause for multi-line text. Co-Authored-By: Claude Sonnet 4.5 --- src/writer/vegalite/encoding.rs | 4 +- src/writer/vegalite/layer.rs | 19 ++--- src/writer/vegalite/mod.rs | 119 +++++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 19 deletions(-) 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 39dd21f5..f0c70bf3 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -654,6 +654,9 @@ impl TextRenderer { } /// 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"); @@ -667,25 +670,13 @@ impl TextRenderer { 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; }; - // Normalize literal \\n to actual \n, then split on any newline type - // - Actual \n: from database columns, CHAR(10), or imported data - // - Literal \\n: from SQL string literals like 'Line 1\nLine 2' - // .lines() handles all platform newline types (\n, \r\n, \r) - let normalized = label_str.replace("\\n", "\n"); - let lines: Vec<&str> = normalized.lines().collect(); - - // Only convert to array if we have multiple lines - if lines.len() <= 1 { - continue; // Single line or empty, leave as-is - } - - obj.insert(label_col.clone(), json!(lines)); + // Use shared function for consistent newline splitting + obj.insert(label_col.clone(), super::split_label_on_newlines(label_str)); } Ok(()) } 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};