Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/writer/vegalite/encoding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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());
}
}
Expand Down
108 changes: 107 additions & 1 deletion src/writer/vegalite/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
119 changes: 116 additions & 3 deletions src/writer/vegalite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) => {}
}
Expand Down Expand Up @@ -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};
Expand Down
Loading