Skip to content

Commit 37633f0

Browse files
authored
Support multi-line text labels by splitting on newlines (#301)
1 parent 5f4e972 commit 37633f0

3 files changed

Lines changed: 225 additions & 6 deletions

File tree

src/writer/vegalite/encoding.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ fn apply_title_to_encoding(
415415
.and_then(|labels| labels.labels.get(primary));
416416

417417
if let Some(label) = explicit_label {
418-
encoding["title"] = json!(label);
418+
encoding["title"] = super::split_label_on_newlines(label);
419419
titled_families.insert(primary.to_string());
420420
} else if let Some(orig) = original_name {
421421
// Use original column name as default title when available
@@ -429,7 +429,7 @@ fn apply_title_to_encoding(
429429
// Variant without primary: allow first variant to claim title (for explicit labels)
430430
if let Some(ref labels) = spec.labels {
431431
if let Some(label) = labels.labels.get(primary) {
432-
encoding["title"] = json!(label);
432+
encoding["title"] = super::split_label_on_newlines(label);
433433
titled_families.insert(primary.to_string());
434434
}
435435
}

src/writer/vegalite/layer.rs

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,34 @@ impl TextRenderer {
653653
Ok((result_df, run_lengths))
654654
}
655655

656+
/// Split label values containing newlines into arrays of strings
657+
///
658+
/// Uses the shared split_label_on_newlines function to ensure consistent
659+
/// newline handling across all label types (text data, axis labels, titles, etc.)
660+
fn split_label_newlines(values: &mut [Value]) -> Result<()> {
661+
let label_col = naming::aesthetic_column("label");
662+
663+
for row in values.iter_mut() {
664+
// Get the object, skip if not an object
665+
let Some(obj) = row.as_object_mut() else {
666+
continue;
667+
};
668+
669+
// Get the label value, skip if not present
670+
let Some(label_value) = obj.get(&label_col) else {
671+
continue;
672+
};
673+
// Get the string value, skip if not a string
674+
let Some(label_str) = label_value.as_str() else {
675+
continue;
676+
};
677+
678+
// Use shared function for consistent newline splitting
679+
obj.insert(label_col.clone(), super::split_label_on_newlines(label_str));
680+
}
681+
Ok(())
682+
}
683+
656684
/// Convert typeface to Vega-Lite font value
657685
/// Prefers literal over column value
658686
fn convert_typeface(
@@ -1032,12 +1060,15 @@ impl GeomRenderer for TextRenderer {
10321060
// Slice the contiguous run from the DataFrame (more efficient than boolean masking)
10331061
let sliced = df.slice(position as i64, length);
10341062

1035-
let values = if binned_columns.is_empty() {
1063+
let mut values = if binned_columns.is_empty() {
10361064
dataframe_to_values(&sliced)?
10371065
} else {
10381066
dataframe_to_values_with_bins(&sliced, binned_columns)?
10391067
};
10401068

1069+
// Post-process label values to split on newlines
1070+
Self::split_label_newlines(&mut values)?;
1071+
10411072
components.insert(suffix, values);
10421073
position += length;
10431074
}
@@ -2784,6 +2815,81 @@ mod tests {
27842815
assert!(labels.contains(&"$21.00"));
27852816
}
27862817

2818+
#[test]
2819+
fn test_text_label_newline_splitting() {
2820+
use crate::execute;
2821+
use crate::reader::DuckDBReader;
2822+
use crate::writer::vegalite::VegaLiteWriter;
2823+
use crate::writer::Writer;
2824+
2825+
// Test that labels containing newlines are split into arrays
2826+
2827+
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
2828+
2829+
// Query with labels containing newlines in DRAW and PLACE
2830+
let query = r#"
2831+
SELECT
2832+
n::INTEGER as x,
2833+
n::INTEGER as y,
2834+
CASE
2835+
WHEN n = 0 THEN 'First Line\nSecond Line'
2836+
WHEN n = 1 THEN 'Single Line'
2837+
ELSE 'Line 1\nLine 2\nLine 3'
2838+
END as label
2839+
FROM generate_series(0, 2) as t(n)
2840+
VISUALISE x, y, label
2841+
DRAW text
2842+
PLACE text SETTING x => 5, y => 15, label => 'Annotation\nWith Newline'
2843+
"#;
2844+
2845+
let prepared = execute::prepare_data_with_reader(query, &reader).unwrap();
2846+
let spec = &prepared.specs[0];
2847+
2848+
let writer = VegaLiteWriter::new();
2849+
let json_str = writer.write(spec, &prepared.data).unwrap();
2850+
let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2851+
2852+
let data_values = vl_spec["data"]["values"].as_array().unwrap();
2853+
let label_col = crate::naming::aesthetic_column("label");
2854+
2855+
// Check first label (contains newline - should be array)
2856+
let label_0 = &data_values[0][&label_col];
2857+
assert!(label_0.is_array(), "Label with newline should be an array");
2858+
let lines_0 = label_0.as_array().unwrap();
2859+
assert_eq!(lines_0.len(), 2);
2860+
assert_eq!(lines_0[0].as_str().unwrap(), "First Line");
2861+
assert_eq!(lines_0[1].as_str().unwrap(), "Second Line");
2862+
2863+
// Check second label (no newline - should be string)
2864+
let label_1 = &data_values[1][&label_col];
2865+
assert!(
2866+
label_1.is_string(),
2867+
"Label without newline should be a string"
2868+
);
2869+
assert_eq!(label_1.as_str().unwrap(), "Single Line");
2870+
2871+
// Check third label (multiple newlines - should be array with 3 elements)
2872+
let label_2 = &data_values[2][&label_col];
2873+
assert!(label_2.is_array(), "Label with newlines should be an array");
2874+
let lines_2 = label_2.as_array().unwrap();
2875+
assert_eq!(lines_2.len(), 3);
2876+
assert_eq!(lines_2[0].as_str().unwrap(), "Line 1");
2877+
assert_eq!(lines_2[1].as_str().unwrap(), "Line 2");
2878+
assert_eq!(lines_2[2].as_str().unwrap(), "Line 3");
2879+
2880+
// Check PLACE annotation layer (index 3, after the 3 DRAW data rows)
2881+
assert!(data_values.len() > 3, "Should have annotation data");
2882+
let annotation_label = &data_values[3][&label_col];
2883+
assert!(
2884+
annotation_label.is_array(),
2885+
"Annotation label with newline should be an array"
2886+
);
2887+
let annotation_lines = annotation_label.as_array().unwrap();
2888+
assert_eq!(annotation_lines.len(), 2);
2889+
assert_eq!(annotation_lines[0].as_str().unwrap(), "Annotation");
2890+
assert_eq!(annotation_lines[1].as_str().unwrap(), "With Newline");
2891+
}
2892+
27872893
#[test]
27882894
fn test_text_setting_fontweight() {
27892895
use crate::execute;

src/writer/vegalite/mod.rs

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ const POINTS_TO_PIXELS: f64 = 96.0 / 72.0;
4949
/// So: area_px^2 = pi * (r_pt * POINTS_TO_PIXELS)^2 = pi * r_pt^2 * (96/72)^2
5050
const POINTS_TO_AREA: f64 = std::f64::consts::PI * POINTS_TO_PIXELS * POINTS_TO_PIXELS;
5151

52+
/// Split a label string on newlines and return appropriate JSON value
53+
///
54+
/// Returns a JSON array if the string contains multiple lines, or a JSON string
55+
/// if it's a single line. Handles both actual newlines (from database columns,
56+
/// CHAR(10), imported data) and literal \\n (from SQL string literals).
57+
fn split_label_on_newlines(label: &str) -> Value {
58+
// Normalize literal \\n to actual \n, then split on any newline type
59+
let normalized = label.replace("\\n", "\n");
60+
let lines: Vec<&str> = normalized.lines().collect();
61+
62+
// Return array if multiple lines, string if single line
63+
if lines.len() > 1 {
64+
json!(lines)
65+
} else {
66+
json!(label)
67+
}
68+
}
69+
5270
/// Result of preparing layer data for rendering
5371
///
5472
/// Contains the datasets, renderers, and prepared data needed to build Vega-Lite layers.
@@ -1097,13 +1115,20 @@ impl Writer for VegaLiteWriter {
10971115
match (title, subtitle) {
10981116
(Some(t), Some(st)) => {
10991117
// Vega-Lite uses an object for title + subtitle
1100-
vl_spec["title"] = json!({"text": t, "subtitle": st});
1118+
// Split both title and subtitle on newlines
1119+
vl_spec["title"] = json!({
1120+
"text": split_label_on_newlines(t),
1121+
"subtitle": split_label_on_newlines(st)
1122+
});
11011123
}
11021124
(Some(t), None) => {
1103-
vl_spec["title"] = json!(t);
1125+
vl_spec["title"] = split_label_on_newlines(t);
11041126
}
11051127
(None, Some(st)) => {
1106-
vl_spec["title"] = json!({"text": "", "subtitle": st});
1128+
vl_spec["title"] = json!({
1129+
"text": "",
1130+
"subtitle": split_label_on_newlines(st)
1131+
});
11071132
}
11081133
(None, None) => {}
11091134
}
@@ -1558,6 +1583,94 @@ mod tests {
15581583
assert_eq!(vl_spec["layer"][0]["mark"]["clip"], true);
15591584
}
15601585

1586+
#[test]
1587+
fn test_labels_newline_splitting() {
1588+
use crate::execute;
1589+
use crate::reader::DuckDBReader;
1590+
1591+
// Test that LABEL clause values with newlines are split into arrays
1592+
1593+
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
1594+
1595+
let query = r#"
1596+
SELECT
1597+
n::INTEGER as x,
1598+
n::INTEGER as y,
1599+
CASE WHEN n % 2 = 0 THEN 'Group A' ELSE 'Group B' END as category
1600+
FROM generate_series(0, 2) as t(n)
1601+
VISUALISE x, y, category AS stroke
1602+
DRAW point
1603+
LABEL
1604+
title => 'Multi-line\nChart Title',
1605+
subtitle => 'Line 1\nLine 2\nLine 3',
1606+
x => 'X Axis\nWith Newline',
1607+
y => 'Single Line',
1608+
stroke => 'Category\nMulti-line Legend'
1609+
"#;
1610+
1611+
let prepared = execute::prepare_data_with_reader(query, &reader).unwrap();
1612+
let spec = &prepared.specs[0];
1613+
1614+
let writer = VegaLiteWriter::new();
1615+
let json_str = writer.write(spec, &prepared.data).unwrap();
1616+
let vl_spec: Value = serde_json::from_str(&json_str).unwrap();
1617+
1618+
// Check title (should be object with text and subtitle)
1619+
assert!(vl_spec["title"].is_object(), "Title should be an object");
1620+
let title_obj = vl_spec["title"].as_object().unwrap();
1621+
1622+
// Check title text (multi-line, should be array)
1623+
assert!(
1624+
title_obj["text"].is_array(),
1625+
"Title with newline should be an array"
1626+
);
1627+
let title_lines = title_obj["text"].as_array().unwrap();
1628+
assert_eq!(title_lines.len(), 2);
1629+
assert_eq!(title_lines[0].as_str().unwrap(), "Multi-line");
1630+
assert_eq!(title_lines[1].as_str().unwrap(), "Chart Title");
1631+
1632+
// Check subtitle (multi-line, should be array)
1633+
assert!(
1634+
title_obj["subtitle"].is_array(),
1635+
"Subtitle with newlines should be an array"
1636+
);
1637+
let subtitle_lines = title_obj["subtitle"].as_array().unwrap();
1638+
assert_eq!(subtitle_lines.len(), 3);
1639+
assert_eq!(subtitle_lines[0].as_str().unwrap(), "Line 1");
1640+
assert_eq!(subtitle_lines[1].as_str().unwrap(), "Line 2");
1641+
assert_eq!(subtitle_lines[2].as_str().unwrap(), "Line 3");
1642+
1643+
// Check x axis label (multi-line, should be array)
1644+
let x_encoding = &vl_spec["layer"][0]["encoding"]["x"];
1645+
assert!(
1646+
x_encoding["title"].is_array(),
1647+
"X axis label with newline should be an array"
1648+
);
1649+
let x_label_lines = x_encoding["title"].as_array().unwrap();
1650+
assert_eq!(x_label_lines.len(), 2);
1651+
assert_eq!(x_label_lines[0].as_str().unwrap(), "X Axis");
1652+
assert_eq!(x_label_lines[1].as_str().unwrap(), "With Newline");
1653+
1654+
// Check y axis label (single line, should be string)
1655+
let y_encoding = &vl_spec["layer"][0]["encoding"]["y"];
1656+
assert!(
1657+
y_encoding["title"].is_string(),
1658+
"Y axis label without newline should be a string"
1659+
);
1660+
assert_eq!(y_encoding["title"].as_str().unwrap(), "Single Line");
1661+
1662+
// Check stroke legend label (multi-line, should be array)
1663+
let stroke_encoding = &vl_spec["layer"][0]["encoding"]["stroke"];
1664+
assert!(
1665+
stroke_encoding["title"].is_array(),
1666+
"Stroke legend title with newline should be an array"
1667+
);
1668+
let stroke_label_lines = stroke_encoding["title"].as_array().unwrap();
1669+
assert_eq!(stroke_label_lines.len(), 2);
1670+
assert_eq!(stroke_label_lines[0].as_str().unwrap(), "Category");
1671+
assert_eq!(stroke_label_lines[1].as_str().unwrap(), "Multi-line Legend");
1672+
}
1673+
15611674
#[test]
15621675
fn test_fontsize_linear_scaling() {
15631676
use crate::plot::{ArrayElement, OutputRange, Scale, ScaleType};

0 commit comments

Comments
 (0)