collective

Browse / github.com/storytold/gridcraft / patch

Improve chart formatting and legend positioning

Improve chart formatting and legend positioning This pull request improves chart editing in GridCraft. Changes: - configurable chart font family and font size; - configurable X/Y axis-number font size; - configurable axis-title, legend, and main-title typography; - freely draggable chart legend; - improved chart text rendering; - tests covering chart text styles and rendering. All workspace tests pass. Note: XLSX persistence for the new custom typography and legend position will require a follow-up implementation. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary

Status
open
Score
0.00
Adopters
0 other operators
Author
naceurh (imported from pull request #2742, open)
Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb
Size
+819 −137 in 35 files
Review
pending
Shared
2026-10-28
Id
a94639e23c26d5c5d8d84137

How it went

No other operator has reported using it yet.

Diff

raw · crates/chart/src/lib.rs crates/chart/src/render.rs crates/chart/src/render/frame.rs crates/chart/src/render/legend.rs crates/chart/src/resolve.rs crates/chart/src/tests.rs crates/engine/src/chart_svg.rs crates/engine/src/cmd/extra.rs crates/engine/src/cmd/extra/chart.rs crates/engine/src/cmd/forecast.rs crates/engine/src/cmd/insert.rs crates/engine/src/cmd/insert/chart.rs crates/engine/src/cmd/pivot/charts.rs crates/engine/src/cmd/print/draw.rs crates/engine/src/io/tests.rs crates/engine/src/tests.rs crates/model/src/features.rs crates/ui-egui/src/app/fonts.rs crates/ui-egui/src/chartview.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/dialogs/charts.rs crates/ui-egui/src/dialogs/show.rs crates/ui-egui/src/grid.rs crates/ui-egui/src/grid/cursor.rs crates/ui-egui/src/grid/interact.rs crates/ui-egui/src/panes.rs crates/xlsx/src/chart.rs crates/xlsx/src/chartex.rs crates/xlsx/src/tests/chart_book_names.rs crates/xlsx/src/tests/fixtures.rs crates/xlsx/src/tests/literal_category_roundtrip.rs crates/xlsx/src/tests/multilevel_category_roundtrip.rs crates/xlsx/src/tests/roundtrip.rs crates/xlsx/src/tests/smooth_roundtrip.rs crates/xlsx/src/tests/trendline_roundtrip.rs

diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs--- a/crates/chart/src/lib.rs+++ b/crates/chart/src/lib.rs@@ -95,6 +95,9 @@ pub enum Prim {         y: f32,         text: String,         size: f32,+        /// Optional font family. `None` uses the renderer's default chart font.+        #[serde(default)]+        font: Option<String>,         color: Rgba,         bold: bool,         align: HAlign,@@ -106,6 +109,12 @@ pub enum Prim { /// Measures text width (the UI passes real font metrics; tests use a fixed-width approximation). pub trait Measure {     fn text_width(&self, text: &str, size: f32, bold: bool) -> f32;++    /// Measures a text run using an optional chart font. Implementations that do not distinguish+    /// font families may use the default measurement, which keeps existing exporters compatible.+    fn text_width_with_font(&self, text: &str, size: f32, bold: bool, _font: Option<&str>) -> f32 {+        self.text_width(text, size, bold)+    } }  /// Fixed-width approximation: `0.55 × size` per character (bold 10% wider).diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs--- a/crates/chart/src/render.rs+++ b/crates/chart/src/render.rs@@ -3,7 +3,7 @@ use std::collections::HashSet; use std::f32::consts::{PI, TAU}; -use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind};+use gridcraft_model::{Chart, ChartKind, ChartTextStyle, ChartTextStyles, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind};  use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale}; use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};@@ -48,6 +48,16 @@ const MAX_LABELS: usize = 200; struct Ctx<'a> {     out: Vec<Prim>,     m: &'a dyn Measure,+    text_styles: &'a ChartTextStyles,+}++#[derive(Clone, Copy)]+pub(super) enum TextRole {+    General,+    Title,+    AxisLabels,+    AxisTitles,+    Legend, }  impl Ctx<'_> {@@ -55,6 +65,60 @@ impl Ctx<'_> {         let w = self.m.text_width(s, size, false);         if w.is_finite() { w.max(0.0) } else { 0.0 }     }+    fn style(&self, role: TextRole) -> &ChartTextStyle {+        match role {+            TextRole::General => &self.text_styles.general,+            TextRole::Title => &self.text_styles.title,+            TextRole::AxisLabels => &self.text_styles.axis_labels,+            TextRole::AxisTitles => &self.text_styles.axis_titles,+            TextRole::Legend => &self.text_styles.legend,+        }+    }+    fn font(&self, role: TextRole) -> Option<String> {+        self.style(role).font.clone().or_else(|| self.text_styles.general.font.clone())+    }+    fn size(&self, role: TextRole, fallback: f32) -> f32 {+        self.style(role).size.or(self.text_styles.general.size).unwrap_or(fallback).clamp(1.0, 200.0)+    }+    fn tw_role(&self, s: &str, fallback: f32, role: TextRole) -> f32 {+        let size = self.size(role, fallback);+        let font = self.font(role);+        let w = self.m.text_width_with_font(s, size, false, font.as_deref());+        if w.is_finite() { w.max(0.0) } else { 0.0 }+    }+    fn fit_role(&self, s: &str, fallback: f32, maxw: f32, role: TextRole) -> String {+        if self.tw_role(s, fallback, role) <= maxw {+            return s.to_string();+        }+        let chars: Vec<char> = s.chars().collect();+        let mut n = chars.len().min(400);+        while n > 0 {+            n -= 1;+            let t: String = chars.iter().take(n).collect::<String>() + "…";+            if self.tw_role(&t, fallback, role) <= maxw {+                return if n == 0 { String::new() } else { t };+            }+        }+        String::new()+    }+    #[allow(clippy::too_many_arguments)]+    fn text_role(&mut self, role: TextRole, x: f32, y: f32, s: &str, fallback: f32, color: Rgba, align: HAlign, valign: VAlign) {+        if s.is_empty() {+            return;+        }+        self.out.push(Prim::Text {+            x,+            y,+            text: s.to_string(),+            size: self.size(role, fallback),+            font: self.font(role),+            color,+            bold: false,+            align,+            valign,+            rotation: 0.0,+        });+    }     fn fit(&self, s: &str, size: f32, maxw: f32) -> String {         if self.tw(s, size) <= maxw {             return s.to_string();@@ -75,7 +139,18 @@ impl Ctx<'_> {         if s.is_empty() {             return;         }-        self.out.push(Prim::Text { x, y, text: s.to_string(), size, color, bold: false, align, valign, rotation: 0.0 });+        self.out.push(Prim::Text {+            x,+            y,+            text: s.to_string(),+            size,+            font: self.font(TextRole::General),+            color,+            bold: false,+            align,+            valign,+            rotation: 0.0,+        });     }     fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, fill: Rgba, stroke: Option<(Rgba, f32)>) {         self.out.push(Prim::Rect { x, y, w, h, fill: Some(fill), stroke, radius: 0.0 });@@ -128,21 +203,21 @@ pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure)         return vec![];     }     let data = sanitize(data);-    let mut ctx = Ctx { out: Vec::new(), m };+    let mut ctx = Ctx { out: Vec::new(), m, text_styles: &chart.text };     ctx.out.push(Prim::Rect { x: 0.0, y: 0.0, w, h, fill: Some(WHITE), stroke: Some((GRID, 1.0)), radius: 0.0 });     let pad = (w.min(h) * 0.04).clamp(2.0, 10.0);     let mut area = Bx { x: pad, y: pad, w: w - 2.0 * pad, h: h - 2.0 * pad };     if let Some(t) = chart_title(chart, &data) {-        let size = TITLE_SIZE.min(h * 0.12).max(4.0);-        let t = ctx.fit(&t, size, area.w);-        ctx.text(w / 2.0, area.y, &t, size, TEXT, HAlign::Center, VAlign::Top);+        let size = ctx.size(TextRole::Title, TITLE_SIZE).min(h * 0.12).max(4.0);+        let t = ctx.fit_role(&t, TITLE_SIZE, area.w, TextRole::Title);+        ctx.text_role(TextRole::Title, w / 2.0, area.y, &t, TITLE_SIZE, TEXT, HAlign::Center, VAlign::Top);         let used = size * 1.3 + 4.0;         area.y += used;         area.h -= used;     }     let entries = legend_entries(chart, &data);     if !entries.is_empty() && area.ok() {-        area = draw_legend(&mut ctx, &entries, chart.legend, area);+        area = draw_legend(&mut ctx, &entries, chart.legend, area, chart.legend_position);     }     if area.ok() && !data.series.is_empty() {         draw_kind(&mut ctx, chart, &data, area);@@ -152,6 +227,9 @@ pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure)  fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {     use ChartKind::*;+    let independent_numeric_line = matches!(chart.kind, Line | LineMarkers)+        && data.series.len() > 1+        && data.series.iter().all(|s| s.x.as_ref().is_some_and(|xs| !xs.is_empty()));     match chart.kind {         Pie => pie(ctx, chart, data, area, 0.0, false),         Doughnut => pie(ctx, chart, data, area, 0.5, false),@@ -167,6 +245,13 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {             match chart.kind {                 BarClustered | BarStacked | BarStacked100 => cartesian(ctx, chart, data, area, true),                 Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area),+                Line | LineMarkers if independent_numeric_line => {+                    // Reuse the scatter renderer so each series keeps its own numeric X range.+                    // ScatterLines also draws the connecting segments and markers.+                    let mut numeric_chart = chart.clone();+                    numeric_chart.kind = ScatterLines;+                    scatter(ctx, &numeric_chart, data, area);+                }                 Histogram => histogram(ctx, chart, data, area),                 Waterfall => waterfall(ctx, chart, data, area),                 BoxWhisker => box_whisker(ctx, chart, data, area),@@ -244,11 +329,11 @@ pub(crate) fn finalize(prims: Vec<Prim>, w: f32, h: f32) -> Vec<Prim> {                 }                 Some(Prim::Circle { cx: x, cy: y, r, fill, stroke: clamp_stroke(stroke) })             }-            Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {+            Prim::Text { x, y, text, size, font, color, bold, align, valign, rotation } => {                 if !(fin(x) && fin(y) && fin(size) && fin(rotation)) || text.is_empty() {                     return None;                 }-                Some(Prim::Text { x: cx(x), y: cy(y), text, size: size.clamp(1.0, 200.0), color, bold, align, valign, rotation })+                Some(Prim::Text { x: cx(x), y: cy(y), text, size: size.clamp(1.0, 200.0), font, color, bold, align, valign, rotation })             }         })         .collect()diff --git a/crates/chart/src/render/frame.rs b/crates/chart/src/render/frame.rs--- a/crates/chart/src/render/frame.rs+++ b/crates/chart/src/render/frame.rs@@ -41,20 +41,22 @@ pub(super) fn chart_title(chart: &Chart, data: &ChartData) -> Option<String> { }  pub(super) fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx {-    let lh = AXIS_TITLE_SIZE * 1.4 + 2.0;+    let title_size = ctx.size(TextRole::AxisTitles, AXIS_TITLE_SIZE);+    let lh = title_size * 1.4 + 2.0;     if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {-        let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w);-        ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom);+        let t = ctx.fit_role(t, AXIS_TITLE_SIZE, area.w, TextRole::AxisTitles);+        ctx.text_role(TextRole::AxisTitles, area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom);         area.h -= lh;     }     if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {-        let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h);+        let t = ctx.fit_role(t, AXIS_TITLE_SIZE, area.h, TextRole::AxisTitles);         if !t.is_empty() {             ctx.out.push(Prim::Text {                 x: area.x,                 y: area.cy(),                 text: t,-                size: AXIS_TITLE_SIZE,+                size: title_size,+                font: ctx.font(TextRole::AxisTitles),                 color: TEXT,                 bold: false,                 align: HAlign::Center,@@ -98,16 +100,16 @@ impl ValAxis<'_> {  /// Draws gridlines, axis labels and the category axis line; returns the plot rectangle. pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx {-    let ls = LABEL_SIZE;+    let ls = ctx.size(TextRole::AxisLabels, LABEL_SIZE);     let lh = ls * 1.4;     let ylabels = y.labels();-    let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);+    let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels)).fold(0.0f32, f32::max);     if !horizontal {         let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);         let y2labels = y2.map(ValAxis::labels);         let right = match (&y2labels, &cat) {             (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),-            (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),+            (None, CatAxis::Numeric(s, f)) => (ctx.tw_role(&f.format(s.max, Some(s.step)), LABEL_SIZE, TextRole::AxisLabels) / 2.0).min(area.w * 0.2),             _ => 4.0,         };         let levels = match &cat {@@ -123,12 +125,12 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti             if grid {                 ctx.hline(plot.x, plot.right(), yy, GRID, 0.75);             }-            ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle);+            ctx.text_role(TextRole::AxisLabels, plot.x - 4.0, yy, t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle);         }         if let (Some(a), Some(l)) = (y2, &y2labels) {             for (v, t) in l {                 let yy = plot.bottom() - a.scale.frac(*v) * plot.h;-                ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle);+                ctx.text_role(TextRole::AxisLabels, plot.right() + 4.0, yy, t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle);             }         }         let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };@@ -138,11 +140,20 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti             CatAxis::Labels(l, outer) => {                 let n = l.len().max(1);                 let cw = plot.w / n as f32;-                let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);+                let lw = l.iter().take(2000).map(|t| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels)).fold(0.0f32, f32::max);                 let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n);                 for (j, t) in l.iter().enumerate().step_by(k) {-                    let t = ctx.fit(t, ls, cw * k as f32 - 2.0);-                    ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top);+                    let t = ctx.fit_role(t, LABEL_SIZE, cw * k as f32 - 2.0, TextRole::AxisLabels);+                    ctx.text_role(+                        TextRole::AxisLabels,+                        plot.x + (j as f32 + 0.5) * cw,+                        plot.bottom() + 3.0,+                        &t,+                        LABEL_SIZE,+                        TEXT,+                        HAlign::Center,+                        VAlign::Top,+                    );                 }                 // Outer levels, one row each below the labels: a group's name centred under its                 // categories, and a separator at each group boundary down through the rows.@@ -151,8 +162,8 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti                     for (first, end) in level_groups(level, n) {                         let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw);                         if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {-                            let t = ctx.fit(t, ls, x1 - x0 - 2.0);-                            ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top);+                            let t = ctx.fit_role(t, LABEL_SIZE, x1 - x0 - 2.0, TextRole::AxisLabels);+                            ctx.text_role(TextRole::AxisLabels, (x0 + x1) / 2.0, top, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top);                         }                         if first > 0 {                             ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75);@@ -164,12 +175,12 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti             CatAxis::Numeric(s, f) => {                 let ticks = s.ticks();                 let labels: Vec<String> = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect();-                let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);+                let lw = labels.iter().map(|t| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels)).fold(0.0f32, f32::max);                 let slot = plot.w / ticks.len().max(1) as f32;                 let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1);                 for (v, t) in ticks.iter().zip(&labels).step_by(k) {                     let xx = plot.x + s.frac(*v) * plot.w;-                    ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top);+                    ctx.text_role(TextRole::AxisLabels, xx, plot.bottom() + 3.0, t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top);                 }                 if s.min <= 0.0 && s.max >= 0.0 {                     let xx = plot.x + s.frac(0.0) * plot.w;@@ -183,12 +194,14 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti             CatAxis::Labels(l, o) => (l.to_vec(), o),             CatAxis::Numeric(..) => (vec![], &[]),         };-        let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);+        let lw = (labels.iter().take(2000).map(|t| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);         // Outer levels stand in columns left of the labels, each as wide as its longest name.-        let level_w: Vec<f32> =-            outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect();+        let level_w: Vec<f32> = outer+            .iter()+            .map(|l| (l.iter().take(2000).map(|t| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15))+            .collect();         let levels_w: f32 = level_w.iter().sum();-        let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;+        let right = ylabels.last().map(|(_, t)| ctx.tw_role(t, LABEL_SIZE, TextRole::AxisLabels) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;         let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 };         if !plot.ok() {             return plot;@@ -202,7 +215,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti                 ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75);             }             if i % k == 0 {-                ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top);+                ctx.text_role(TextRole::AxisLabels, xx, plot.bottom() + 3.0, t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top);             }         }         let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };@@ -212,8 +225,17 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti         let ch = plot.h / n as f32;         let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n);         for (j, t) in labels.iter().enumerate().step_by(k) {-            let t = ctx.fit(t, ls, lw - 6.0);-            ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle);+            let t = ctx.fit_role(t, LABEL_SIZE, lw - 6.0, TextRole::AxisLabels);+            ctx.text_role(+                TextRole::AxisLabels,+                plot.x - 4.0,+                plot.bottom() - (j as f32 + 0.5) * ch,+                &t,+                LABEL_SIZE,+                TEXT,+                HAlign::Right,+                VAlign::Middle,+            );         }         // Outer levels: a group's name centred beside its categories, separators at the         // boundaries running right to the axis.@@ -223,8 +245,8 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti             for (first, end) in level_groups(level, n) {                 let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch);                 if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {-                    let t = ctx.fit(t, ls, w - 4.0);-                    ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle);+                    let t = ctx.fit_role(t, LABEL_SIZE, w - 4.0, TextRole::AxisLabels);+                    ctx.text_role(TextRole::AxisLabels, (x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Middle);                 }                 if first > 0 {                     ctx.hline(x0, plot.x, y1, AXIS, 0.75);diff --git a/crates/chart/src/render/legend.rs b/crates/chart/src/render/legend.rs--- a/crates/chart/src/render/legend.rs+++ b/crates/chart/src/render/legend.rs@@ -93,69 +93,87 @@ fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba)     } } -pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx {-    let size = LABEL_SIZE;+pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx, custom: Option<[f32; 2]>) -> Bx {+    if let Some([x, y]) = custom {+        let x = x.clamp(area.x, area.right());+        let y = y.clamp(area.y, area.bottom());+        let custom_area = Bx { x, y, w: (area.right() - x).max(4.0), h: (area.bottom() - y).max(4.0) };+        let _ = draw_horizontal_legend(ctx, entries, custom_area, false);+        return area;+    }+    match pos {+        LegendPos::None => area,+        LegendPos::Top | LegendPos::Bottom => {+            let used = draw_horizontal_legend(ctx, entries, area, pos == LegendPos::Bottom);+            if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }+        }+        LegendPos::Left | LegendPos::Right => draw_side_legend(ctx, entries, area, pos == LegendPos::Right),+    }+}++fn draw_horizontal_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], area: Bx, bottom: bool) -> f32 {+    let size = ctx.size(TextRole::Legend, LABEL_SIZE);     let row_h = size * 1.6;     let s = size * 0.8;     let gap = 4.0;     let spacing = size * 1.3;-    let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size);-    match pos {-        LegendPos::None => area,-        LegendPos::Top | LegendPos::Bottom => {-            let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1);-            let mut rows: Vec<Vec<(usize, f32)>> = vec![vec![]];-            let mut cur = 0.0f32;-            for (i, e) in entries.iter().enumerate() {-                let iw = item_w(ctx, e).min(area.w);-                let need = if cur > 0.0 { spacing + iw } else { iw };-                if cur > 0.0 && cur + need > area.w {-                    if rows.len() >= max_rows {-                        break;-                    }-                    rows.push(vec![]);-                    cur = 0.0;-                }-                cur += if cur > 0.0 { spacing + iw } else { iw };-                if let Some(r) = rows.last_mut() {-                    r.push((i, iw));-                }-            }-            let lh = rows.len() as f32 * row_h;-            let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y };-            for (ri, row) in rows.iter().enumerate() {-                let total: f32 = row.iter().map(|(_, w)| w).sum::<f32>() + spacing * row.len().saturating_sub(1) as f32;-                let mut x = area.x + (area.w - total).max(0.0) / 2.0;-                let yc = y0 + ri as f32 * row_h + row_h / 2.0;-                for &(i, iw) in row {-                    if let Some(e) = entries.get(i) {-                        draw_swatch(ctx, e.2, x, yc, s, e.1);-                        let sx = swatch_w(e.2, s) + gap;-                        let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5);-                        ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle);-                    }-                    x += iw + spacing;-                }+    let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw_role(&e.0, LABEL_SIZE, TextRole::Legend);+    let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1);+    let mut rows: Vec<Vec<(usize, f32)>> = vec![vec![]];+    let mut cur = 0.0f32;+    for (i, e) in entries.iter().enumerate() {+        let iw = item_w(ctx, e).min(area.w);+        let need = if cur > 0.0 { spacing + iw } else { iw };+        if cur > 0.0 && cur + need > area.w {+            if rows.len() >= max_rows {+                break;             }-            let used = lh + 4.0;-            if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }+            rows.push(vec![]);+            cur = 0.0;+        }+        cur += if cur > 0.0 { spacing + iw } else { iw };+        if let Some(r) = rows.last_mut() {+            r.push((i, iw));         }-        LegendPos::Left | LegendPos::Right => {-            let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);-            let max_rows = ((area.h / row_h).floor() as usize).max(1);-            let n = entries.len().min(max_rows);-            let lh = n as f32 * row_h;-            let y0 = area.y + (area.h - lh).max(0.0) / 2.0;-            let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x };-            for (i, e) in entries.iter().take(n).enumerate() {-                let yc = y0 + i as f32 * row_h + row_h / 2.0;-                draw_swatch(ctx, e.2, x0, yc, s, e.1);+    }+    let lh = rows.len() as f32 * row_h;+    let y0 = if bottom { area.bottom() - lh } else { area.y };+    for (ri, row) in rows.iter().enumerate() {+        let total: f32 = row.iter().map(|(_, w)| w).sum::<f32>() + spacing * row.len().saturating_sub(1) as f32;+        let mut x = area.x + (area.w - total).max(0.0) / 2.0;+        let yc = y0 + ri as f32 * row_h + row_h / 2.0;+        for &(i, iw) in row {+            if let Some(e) = entries.get(i) {+                draw_swatch(ctx, e.2, x, yc, s, e.1);                 let sx = swatch_w(e.2, s) + gap;-                let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5);-                ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle);+                let t = ctx.fit_role(&e.0, LABEL_SIZE, (iw - sx).max(0.0) + 0.5, TextRole::Legend);+                ctx.text_role(TextRole::Legend, x + sx, yc, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle);             }-            let used = colw + 8.0;-            if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } }+            x += iw + spacing;         }     }+    lh + 4.0+}++fn draw_side_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], area: Bx, right: bool) -> Bx {+    let size = ctx.size(TextRole::Legend, LABEL_SIZE);+    let row_h = size * 1.6;+    let s = size * 0.8;+    let gap = 4.0;+    let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw_role(&e.0, LABEL_SIZE, TextRole::Legend);+    let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);+    let max_rows = ((area.h / row_h).floor() as usize).max(1);+    let n = entries.len().min(max_rows);+    let lh = n as f32 * row_h;+    let y0 = area.y + (area.h - lh).max(0.0) / 2.0;+    let x0 = if right { area.right() - colw } else { area.x };+    for (i, e) in entries.iter().take(n).enumerate() {+        let yc = y0 + i as f32 * row_h + row_h / 2.0;+        draw_swatch(ctx, e.2, x0, yc, s, e.1);+        let sx = swatch_w(e.2, s) + gap;+        let t = ctx.fit_role(&e.0, LABEL_SIZE, (colw - sx).max(0.0) + 0.5, TextRole::Legend);+        ctx.text_role(TextRole::Legend, x0 + sx, yc, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle);+    }+    let used = colw + 8.0;+    if right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } } }diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs--- a/crates/chart/src/resolve.rs+++ b/crates/chart/src/resolve.rs@@ -220,11 +220,26 @@ pub fn series_plot_kind(chart: &Chart, i: usize) -> ChartKind {     if chart.kind == ChartKind::Combo { effective_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i) } else { chart.kind } } +/// A line chart normally uses one shared category axis.  When several series+/// provide different numeric category ranges, however, those ranges are really+/// independent X coordinates and must be preserved for numeric rendering.+fn has_independent_numeric_x(wb: &Workbook, sheet: usize, chart: &Chart) -> bool {+    if !matches!(chart.kind, ChartKind::Line | ChartKind::LineMarkers) || chart.series.len() < 2 {+        return false;+    }+    let formulas: Vec<&str> = chart.series.iter().filter_map(|s| s.categories.as_deref()).collect();+    if formulas.len() != chart.series.len() || formulas.windows(2).all(|pair| pair[0] == pair[1]) {+        return false;+    }+    formulas.iter().all(|formula| !eval_list(wb, sheet, formula).iter().any(Value::is_text))+}+ /// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X /// values (scatter/bubble), bubble sizes, number formats and colours (explicit, else the /// default palette derived from the workbook theme accents). pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {-    let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);+    let numeric_x =+        matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble) || has_independent_numeric_x(wb, sheet, chart);     let mut series = Vec::new();     let mut categories: Option<Vec<String>> = None;     let mut category_levels = Vec::new();diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs--- a/crates/chart/src/tests.rs+++ b/crates/chart/src/tests.rs@@ -49,6 +49,8 @@ fn chart(kind: ChartKind) -> Chart {         y_title: Some("Revenue".into()),         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: None,     } }@@ -381,6 +383,80 @@ fn scatter_with_text_x_values_numbers_the_points() {     } } +#[test]+fn line_chart_with_independent_numeric_x_uses_a_numeric_axis() {+    let mut wb = Workbook::new();+    let sh = wb.sheet_mut(0).unwrap();+    for (row, (x1, y1, x2, y2)) in [(0.0, 1.0, 0.0, 2.0), (1.0, 3.0, 2.0, 4.0), (4.0, 2.0, 4.0, 1.0)].into_iter().enumerate() {+        let r = row as u32;+        sh.set_value(CellRef::new(r, 0), Value::Number(x1));+        sh.set_value(CellRef::new(r, 1), Value::Number(y1));+        sh.set_value(CellRef::new(r, 2), Value::Number(x2));+        sh.set_value(CellRef::new(r, 3), Value::Number(y2));+    }+    let mut c = chart(ChartKind::Line);+    c.data_labels = false;+    c.series = vec![+        Series {+            categories: Some("Sheet1!$A$1:$A$3".into()),+            values: "Sheet1!$B$1:$B$3".into(),+            color: Some(Color::Rgb(0x010203)),+            ..Default::default()+        },+        Series {+            categories: Some("Sheet1!$C$1:$C$3".into()),+            values: "Sheet1!$D$1:$D$3".into(),+            color: Some(Color::Rgb(0x040506)),+            ..Default::default()+        },+    ];++    let d = resolve(&wb, 0, &c);+    assert_eq!(d.series[0].x, Some(vec![Some(0.0), Some(1.0), Some(4.0)]));+    assert_eq!(d.series[1].x, Some(vec![Some(0.0), Some(2.0), Some(4.0)]));+    let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);+    let lines: Vec<&Vec<[f32; 2]>> = prims+        .iter()+        .filter_map(|p| match p {+            Prim::Line { pts, color, .. } if *color == [1, 2, 3, 255] => Some(pts),+            _ => None,+        })+        .collect();+    assert!(!lines.is_empty());+    let pts = lines.iter().max_by_key(|pts| pts.len()).copied().unwrap();+    let first = pts[1][0] - pts[0][0];+    let second = pts[2][0] - pts[1][0];+    assert!(second > first * 2.0, "line X coordinates are still category-spaced: {pts:?}");+}++#[test]+fn chart_text_styles_reach_title_axis_labels_and_legend_primitives() {+    let mut c = chart(ChartKind::Line);+    c.data_labels = false;+    c.text.title.font = Some("Test Title Font".into());+    c.text.title.size = Some(22.0);+    c.text.axis_labels.font = Some("Test Axis Font".into());+    c.text.axis_labels.size = Some(15.0);+    c.text.legend.font = Some("Test Legend Font".into());+    c.text.legend.size = Some(13.0);+    let prims = render(&c, &sample(ChartKind::Line), 640.0, 400.0, &ApproxMeasure);+    let title = prims.iter().find_map(|p| match p {+        Prim::Text { text, size, font, .. } if text == "Quarterly Sales" => Some((*size, font.clone())),+        _ => None,+    });+    assert_eq!(title, Some((22.0, Some("Test Title Font".into()))));+    let axis_label = prims.iter().find_map(|p| match p {+        Prim::Text { size, font, .. } if (*size - 15.0).abs() < f32::EPSILON => Some((*size, font.clone())),+        _ => None,+    });+    assert_eq!(axis_label, Some((15.0, Some("Test Axis Font".into()))));+    let legend_label = prims.iter().find_map(|p| match p {+        Prim::Text { text, size, font, .. } if text == "North" => Some((*size, font.clone())),+        _ => None,+    });+    assert_eq!(legend_label, Some((13.0, Some("Test Legend Font".into()))));+}+ fn pixel(img: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {     let i = ((y * w + x) * 4) as usize;     [img[i], img[i + 1], img[i + 2], img[i + 3]]diff --git a/crates/engine/src/chart_svg.rs b/crates/engine/src/chart_svg.rs--- a/crates/engine/src/chart_svg.rs+++ b/crates/engine/src/chart_svg.rs@@ -68,7 +68,7 @@ fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String {                 paint(&mut out, *fill, *stroke);                 out.push_str("/>");             }-            Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {+            Prim::Text { x, y, text, size, font, color, bold, align, valign, rotation } => {                 // Baseline offsets as in the PDF export.                 let dy = match valign {                     VAlign::Top => size * 0.78,@@ -81,6 +81,9 @@ fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String {                     HAlign::Right => "end",                 };                 let _ = write!(out, "<text x=\"{}\" y=\"{}\" font-size=\"{}\" text-anchor=\"{anchor}\"", num(*x), num(y + dy), num(*size));+                if let Some(font) = font.as_deref() {+                    let _ = write!(out, " font-family=\"{}\"", xml_attr(font));+                }                 if *bold {                     out.push_str(" font-weight=\"bold\"");                 }@@ -132,6 +135,10 @@ fn hex(c: Rgba) -> String {     format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) } +fn xml_attr(s: &str) -> String {+    s.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;").replace('>', "&gt;")+}+ fn opacity(attr: &str, c: Rgba) -> String {     if c[3] == 255 { String::new() } else { format!(" {attr}-opacity=\"{:.2}\"", f32::from(c[3]) / 255.0) } }@@ -163,6 +170,7 @@ mod tests {                 y: 6.0,                 text: "A<B".into(),                 size: 10.0,+                font: None,                 color: red,                 bold: true,                 align: HAlign::Center,diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs--- a/crates/engine/src/cmd/extra.rs+++ b/crates/engine/src/cmd/extra.rs@@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Select Data…",             ["Chart Design", "Data"],             None,-            "{chart?, range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}",+            "{chart?, series?: [{name?, x?, y}], range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}",             has_doc,             select_data         ),diff --git a/crates/engine/src/cmd/extra/chart.rs b/crates/engine/src/cmd/extra/chart.rs--- a/crates/engine/src/cmd/extra/chart.rs+++ b/crates/engine/src/cmd/extra/chart.rs@@ -37,7 +37,8 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {                         Some("right") => LegendPos::Right,                         _ => LegendPos::Bottom,                     }-                }+                };+                c.legend_position = None;             }             "dataLabels" => c.data_labels = on,             "gridlines" => c.gridlines = on,@@ -124,6 +125,7 @@ pub(super) fn quick_layout(s: &mut Session, p: &Json) -> Result<Json> {             _ => (LegendPos::Right, false, true, false),         };         c.legend = legend;+        c.legend_position = None;         c.data_labels = labels;         c.gridlines = grid;         if axes {@@ -161,6 +163,35 @@ pub(super) fn change_colors(s: &mut Session, p: &Json) -> Result<Json> {  pub(super) fn select_data(s: &mut Session, p: &Json) -> Result<Json> {     let d = s.doc()?;+    // Explicit series keep each series' name, X range and Y range independent. This is the+    // representation needed by scatter charts whose curves do not share one X axis.+    let explicit_series = match p.get("series") {+        None => None,+        Some(Json::Array(items)) => {+            if items.is_empty() {+                return Err(bad("chart.selectData", "`series` must contain at least one series"));+            }+            if items.len() > 4096 {+                return Err(bad("chart.selectData", "too many chart series"));+            }+            let mut series = Vec::with_capacity(items.len());+            for item in items {+                let obj = item.as_object().ok_or_else(|| bad("chart.selectData", "each series must be an object"))?;+                let name = obj.get("name").and_then(Json::as_str).map(str::trim).filter(|v| !v.is_empty()).map(str::to_string);+                let x = obj.get("x").and_then(Json::as_str).map(str::trim).filter(|v| !v.is_empty()).map(str::to_string);+                let y = obj+                    .get("y")+                    .and_then(Json::as_str)+                    .map(str::trim)+                    .filter(|v| !v.is_empty())+                    .ok_or_else(|| bad("chart.selectData", "each series needs a non-empty Y range"))?+                    .to_string();+                series.push(Series { name, categories: x, values: y, ..Series::default() });+            }+            Some(series)+        }+        Some(_) => return Err(bad("chart.selectData", "`series` must be an array")),+    };     // A new data range rebuilds the series, as in Excel's Select Data › Chart data range.     let rebuilt = match str_param(p, "range") {         Some(range) => {@@ -205,10 +236,15 @@ pub(super) fn select_data(s: &mut Session, p: &Json) -> Result<Json> {         .unwrap_or_default();     remove.sort_unstable();     remove.dedup();-    if rebuilt.is_none() && categories.is_none() && remove.is_empty() {-        return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));+    if explicit_series.is_none() && rebuilt.is_none() && categories.is_none() && remove.is_empty() {+        return Err(bad("chart.selectData", "missing `series`, `range`, `categories` or `removeSeries`"));     }     with_chart(s, p, move |c| {+        if let Some(series) = explicit_series {+            c.series = series;+            c.source = None;+            c.by_rows = false;+        }         if let Some((series, source, by_rows)) = rebuilt {             c.series = series;             crate::cmd::insert::scatter_x_from_first(c.kind, &mut c.series);diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs--- a/crates/engine/src/cmd/forecast.rs+++ b/crates/engine/src/cmd/forecast.rs@@ -430,6 +430,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {             y_title: None,             source: None,             by_rows: false,+            text: Default::default(),+            legend_position: None,             pivot: None,         });         cx.wb.sheets.insert(at, Arc::new(sheet));diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs--- a/crates/engine/src/cmd/insert.rs+++ b/crates/engine/src/cmd/insert.rs@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Chart Properties",             ["Chart Design"],             None,-            "{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",+            "{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, legendPosition?: [x,y], text?: {general|title|axisLabels|axisTitles|legend: {font?, size?}}, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",             has_doc,             chart_set         ),diff --git a/crates/engine/src/cmd/insert/chart.rs b/crates/engine/src/cmd/insert/chart.rs--- a/crates/engine/src/cmd/insert/chart.rs+++ b/crates/engine/src/cmd/insert/chart.rs@@ -263,6 +263,8 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {         y_title: None,         source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),         by_rows,+        text: Default::default(),+        legend_position: None,         pivot: None,     };     Ok((sheet, chart))@@ -337,6 +339,34 @@ pub(super) fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {         if let Some(t) = p.get("yTitle") {             c.y_title = t.as_str().map(str::to_string);         }+        if let Some(text) = p.get("text").and_then(Json::as_object) {+            let apply = |key: &str, style: &mut gridcraft_model::ChartTextStyle| {+                let Some(v) = text.get(key).and_then(Json::as_object) else { return };+                if let Some(font) = v.get("font") {+                    style.font = font.as_str().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);+                }+                if let Some(size) = v.get("size").and_then(Json::as_f64).filter(|v| v.is_finite()) {+                    style.size = Some((size as f32).clamp(4.0, 72.0));+                }+            };+            apply("general", &mut c.text.general);+            apply("title", &mut c.text.title);+            apply("axisLabels", &mut c.text.axis_labels);+            apply("axisTitles", &mut c.text.axis_titles);+            apply("legend", &mut c.text.legend);+        }+        if let Some(pos) = p.get("legendPosition") {+            if pos.is_null() {+                c.legend_position = None;+            } else if let Some(pos) = pos.as_array()+                && pos.len() == 2+                && let (Some(x), Some(y)) = (pos[0].as_f64(), pos[1].as_f64())+                && x.is_finite()+                && y.is_finite()+            {+                c.legend_position = Some([x.clamp(0.0, c.anchor.width as f64) as f32, y.clamp(0.0, c.anchor.height as f64) as f32]);+            }+        }         if let Some(at) = at {             c.anchor.cell = at;         }diff --git a/crates/engine/src/cmd/pivot/charts.rs b/crates/engine/src/cmd/pivot/charts.rs--- a/crates/engine/src/cmd/pivot/charts.rs+++ b/crates/engine/src/cmd/pivot/charts.rs@@ -68,6 +68,8 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {         y_title: None,         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: Some(link),     };     edit(s, |cx| {diff --git a/crates/engine/src/cmd/print/draw.rs b/crates/engine/src/cmd/print/draw.rs--- a/crates/engine/src/cmd/print/draw.rs+++ b/crates/engine/src/cmd/print/draw.rs@@ -135,7 +135,7 @@ pub(super) fn draw_prims(pg: &mut Page, prims: &[gridcraft_chart::Prim], xf: Xf)                 let (px, py) = xf.p(*cx, *cy);                 pg.circle(px, py, r * k, fill.map(blend), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1))));             }-            Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {+            Prim::Text { x, y, text, size, color, bold, align, valign, rotation, .. } => {                 let font = if *bold { Font::HelveticaBold } else { Font::Helvetica };                 let size = size * k;                 let w = gridcraft_pdf::text_width(font, size, text);diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs--- a/crates/engine/src/io/tests.rs+++ b/crates/engine/src/io/tests.rs@@ -29,7 +29,10 @@ fn save_fails_upfront_when_the_temp_name_is_taken() {     let victim = dir.join("victim.txt");     std::fs::write(&victim, b"original").unwrap();     let dest = dir.join("book.xlsx");+    #[cfg(unix)]     std::os::unix::fs::symlink(&victim, dir.join("book.xlsx.sctmp")).unwrap();+    #[cfg(windows)]+    std::os::windows::fs::symlink_file(&victim, dir.join("book.xlsx.sctmp")).unwrap();     let err = write_file(dest.to_str().unwrap(), b"new").unwrap_err();     assert_eq!(std::fs::read(&victim).unwrap(), b"original");     assert!(!dir.join("book.xlsx").exists(), "{err}");diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -200,6 +200,34 @@ fn chart_category_axis_from_blank_corner_and_select_data() {     assert_eq!(series(&s), [(None, "Sheet1!$E$2:$E$4".to_string())]); } +#[test]+fn chart_select_data_accepts_independent_series_ranges() {+    let mut s = s();+    s.execute("range.setValues", json!({"range": "A1", "values": [[0, 10], [1, 20], [2, 30]]})).unwrap();+    s.execute("range.setValues", json!({"range": "D1", "values": [[100, 7], [200, 9], [300, 11]]})).unwrap();+    s.execute("insert.chart", json!({"type": "scatter", "range": "A1:B3"})).unwrap();++    s.execute(+        "chart.selectData",+        json!({+            "series": [+                {"name": "First curve", "x": "Sheet1!$A$1:$A$3", "y": "Sheet1!$B$1:$B$3"},+                {"name": "Second curve", "x": "Sheet1!$D$1:$D$3", "y": "Sheet1!$E$1:$E$3"}+            ]+        }),+    )+    .unwrap();++    let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone();+    assert_eq!(c.series.len(), 2);+    assert_eq!(c.series[0].name.as_deref(), Some("First curve"));+    assert_eq!(c.series[0].categories.as_deref(), Some("Sheet1!$A$1:$A$3"));+    assert_eq!(c.series[0].values, "Sheet1!$B$1:$B$3");+    assert_eq!(c.series[1].name.as_deref(), Some("Second curve"));+    assert_eq!(c.series[1].categories.as_deref(), Some("Sheet1!$D$1:$D$3"));+    assert_eq!(c.series[1].values, "Sheet1!$E$1:$E$3");+}+ #[test] fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() {     let mut s = s();diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs--- a/crates/model/src/features.rs+++ b/crates/model/src/features.rs@@ -540,6 +540,37 @@ impl Trendline {     } } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]+pub struct ChartTextStyle {+    /// Font family override. `None` follows the workbook/UI fallback font.+    #[serde(default)]+    pub font: Option<String>,+    /// Font size in chart points. `None` keeps the automatic size for the role.+    #[serde(default)]+    pub size: Option<f32>,+}++impl Default for ChartTextStyle {+    fn default() -> Self {+        Self { font: None, size: None }+    }+}++#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]+pub struct ChartTextStyles {+    /// Global fallback for all chart text.+    #[serde(default)]+    pub general: ChartTextStyle,+    #[serde(default)]+    pub title: ChartTextStyle,+    #[serde(default)]+    pub axis_labels: ChartTextStyle,+    #[serde(default)]+    pub axis_titles: ChartTextStyle,+    #[serde(default)]+    pub legend: ChartTextStyle,+}+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Chart {     pub id: u32,@@ -556,6 +587,12 @@ pub struct Chart {     /// Source range when created from a selection (for Switch Row/Column).     pub source: Option<String>,     pub by_rows: bool,+    /// Typography overrides for the chart title, axes, legend and general labels.+    #[serde(default)]+    pub text: ChartTextStyles,+    /// Optional free legend position in chart coordinates: top-left `(x, y)`.+    #[serde(default)]+    pub legend_position: Option<[f32; 2]>,     /// A PivotChart's PivotTable (`Sheet2!PivotTable1`): its series are rebuilt from the report     /// whenever the PivotTable is refreshed or its layout changes.     #[serde(default)]diff --git a/crates/ui-egui/src/app/fonts.rs b/crates/ui-egui/src/app/fonts.rs--- a/crates/ui-egui/src/app/fonts.rs+++ b/crates/ui-egui/src/app/fonts.rs@@ -26,6 +26,19 @@ impl SheetApp {             names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone()));             names.push(d.wb.theme.major_font.clone());             names.push(d.wb.theme.minor_font.clone());+            names.extend(d.wb.sheets.iter().flat_map(|sh| {+                sh.charts.iter().flat_map(|c| {+                    [+                        c.text.general.font.clone(),+                        c.text.title.font.clone(),+                        c.text.axis_labels.font.clone(),+                        c.text.axis_titles.font.clone(),+                        c.text.legend.font.clone(),+                    ]+                    .into_iter()+                    .flatten()+                })+            }));             // Text boxes and shapes name their own typefaces.             names.extend(d.wb.sheets.iter().flat_map(|sh| sh.shapes.iter().filter_map(|s| s.format.font.clone())));         }diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs--- a/crates/ui-egui/src/chartview.rs+++ b/crates/ui-egui/src/chartview.rs@@ -24,6 +24,13 @@ impl gridcraft_chart::Measure for EguiMeasure<'_> {         let font = if bold { theme::ui_bold(size * self.z) } else { theme::ui_font(size * self.z) };         self.painter.layout_no_wrap(text.to_string(), font, Color32::BLACK).size().x / self.z.max(0.01)     }++    fn text_width_with_font(&self, text: &str, size: f32, bold: bool, family: Option<&str>) -> f32 {+        let font = family+            .map(|name| FontId::new(size * self.z, theme::cell_family(name, bold, false)))+            .unwrap_or_else(|| if bold { theme::ui_bold(size * self.z) } else { theme::ui_font(size * self.z) });+        self.painter.layout_no_wrap(text.to_string(), font, Color32::BLACK).size().x / self.z.max(0.01)+    } }  /// Paints `chart` (not necessarily on the sheet: the Recommended Charts preview) scaled to fit@@ -118,8 +125,11 @@ pub fn paint_prims(p: &Painter, origin: Pos2, prims: &[Prim], z: f32) {                     p.circle_stroke(pt(*cx, *cy), r * z, Stroke::new(w * z.max(0.5), rgba(*c)));                 }             }-            Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {-                let font: FontId = if *bold { theme::ui_bold(size * z) } else { theme::ui_font(size * z) };+            Prim::Text { x, y, text, size, font, color, bold, align, valign, rotation } => {+                let font: FontId = font+                    .as_deref()+                    .map(|name| FontId::new(size * z, theme::cell_family(name, *bold, false)))+                    .unwrap_or_else(|| if *bold { theme::ui_bold(size * z) } else { theme::ui_font(size * z) });                 let galley = p.layout_no_wrap(text.clone(), font, rgba(*color));                 let sz = galley.size();                 let ax = match align {@@ -492,6 +502,45 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) ->     None } +/// Returns the chart legend under the pointer and its current chart-local origin. The hit box is+/// deliberately generous because the rendered legend can wrap when the chart is narrow.+pub fn legend_drag_info(geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(u32, Pos2, [f32; 2])> {+    for ch in sh.charts.iter().rev() {+        if ch.legend == gridcraft_engine::model::LegendPos::None {+            continue;+        }+        let r = anchor_rect(geo, sh, &ch.anchor);+        if !r.contains(p) {+            continue;+        }+        let w = ch.anchor.width.max(1.0);+        let h = ch.anchor.height.max(1.0);+        let pad = (w.min(h) * 0.04).clamp(2.0, 10.0);+        let title_used = ch.title.as_deref().filter(|t| !t.trim().is_empty()).map(|_| 14.0_f32.min(h * 0.12).max(4.0) * 1.3 + 4.0).unwrap_or(0.0);+        let area = egui::Rect::from_min_size(pos2(pad, pad + title_used), vec2((w - 2.0 * pad).max(4.0), (h - 2.0 * pad - title_used).max(4.0)));+        let origin = if let Some(origin) = ch.legend_position {+            origin+        } else {+            match ch.legend {+                gridcraft_engine::model::LegendPos::Top => [area.left(), area.top()],+                gridcraft_engine::model::LegendPos::Bottom => [area.left(), (area.bottom() - 28.0).max(area.top())],+                gridcraft_engine::model::LegendPos::Left => [area.left(), (area.center().y - 28.0).max(area.top())],+                gridcraft_engine::model::LegendPos::Right => [(area.right() - (w * 0.35)).max(area.left()), (area.center().y - 28.0).max(area.top())],+                gridcraft_engine::model::LegendPos::None => continue,+            }+        };+        let side = matches!(ch.legend, gridcraft_engine::model::LegendPos::Left | gridcraft_engine::model::LegendPos::Right);+        let box_w = if side { (w * 0.4).max(80.0) } else { (w * 0.85).max(100.0) };+        let box_h = if side { (h * 0.45).max(32.0) } else { 34.0 };+        let local = pos2((p.x - r.left()) / geo.z.max(0.01), (p.y - r.top()) / geo.z.max(0.01));+        let hit = egui::Rect::from_min_size(pos2(origin[0], origin[1]), vec2(box_w, box_h));+        if hit.contains(local) {+            return Some((ch.id, p, origin));+        }+    }+    None+}+ fn selection_frame(p: &Painter, r: Rect) {     let t = Tokens::get(p.ctx());     p.rect_stroke(r, 0.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Outside);diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -930,11 +930,23 @@ impl Dialog {                     let sh = doc.wb.sheet(si)?;                     let ch = sh.charts.iter().find(|c| Some(u64::from(c.id)) == id)?;                     let data = gridcraft_chart::resolve(&doc.wb, si, ch);-                    let names: Vec<String> = data.series.iter().map(|x| x.name.clone()).collect();+                    let series: Vec<Json> = ch+                        .series+                        .iter()+                        .enumerate()+                        .map(|(i, s)| {+                            json!({+                                "name": s.name.clone().unwrap_or_else(|| data.series.get(i).map(|x| x.name.clone()).unwrap_or_else(|| format!("Series{}", i + 1))),+                                "x": s.categories.clone().unwrap_or_default(),+                                "y": s.values.clone(),+                            })+                        })+                        .collect();                     let cats = ch.series.first().and_then(|x| x.categories.clone()).unwrap_or_default();-                    Some((ch.source.clone().unwrap_or_default(), cats, names))+                    let names: Vec<String> = data.series.iter().map(|x| x.name.clone()).collect();+                    Some((ch.source.clone().unwrap_or_default(), cats, series, names))                 });-                let Some((range, cats, names)) = found else {+                let Some((range, cats, series, names)) = found else {                     app.message = Some(("Select Data".into(), "Select a chart first.".into()));                     return None;                 };@@ -942,9 +954,23 @@ impl Dialog {                 Dialog::custom(                     "selectData",                     "Select Data Source",-                    json!({"chart": id, "range": eq(&range), "range0": eq(&range), "categories": eq(&cats), "categories0": eq(&cats), "names": names, "remove": []}),+                    json!({"chart": id, "range": eq(&range), "range0": eq(&range), "categories": eq(&cats), "categories0": eq(&cats), "names": names, "series": series, "series0": series, "remove": []}),                 )             }+            "chartSeries" => {+                let mut d = Dialog::custom(+                    "chartSeries",+                    "Edit Series",+                    json!({+                        "index": p("index"),+                        "name": p("name"),+                        "x": p("x"),+                        "y": p("y"),+                    }),+                );+                d.focus_first_field = false;+                d+            }             "journal" => Dialog::custom("journal", "Action Journal", json!({})),             "macros" => {                 let mut d = Dialog::custom("macros", "Macros", json!({}));@@ -1029,6 +1055,23 @@ impl Dialog { mod tests {     use super::*; +    #[test]+    fn chart_series_range_picker_replaces_an_existing_reference() {+        let mut d = Dialog::custom("chartSeries", "Edit Series", json!({"x": "=Sheet1!$C$3:$C$130"}));+        d.pick = Some(RefPick {+            key: "x",+            sheet: String::new(),+            caret: 19,+            point: Some((1, 19)),+            anchor: CellRef::new(0, 0),+            refocus: false,+            applies_to: String::new(),+        });+        d.pick_reference(CellRef::new(2, 4), "Sheet1", false);+        d.pick_reference(CellRef::new(129, 4), "Sheet1", true);+        assert_eq!(d.values["x"], json!("=Sheet1!$E$3:$E$130"));+    }+     #[test]     fn remove_duplicates_prefills_the_selection_and_submits_the_edited_range() {         let mut session = gridcraft_engine::Session::default();diff --git a/crates/ui-egui/src/dialogs/charts.rs b/crates/ui-egui/src/dialogs/charts.rs--- a/crates/ui-egui/src/dialogs/charts.rs+++ b/crates/ui-egui/src/dialogs/charts.rs@@ -122,57 +122,100 @@ pub(super) fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut     } } -/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed.+/// Draws the compact chart data dialog. Each series is edited in its own dialog so long+/// references do not force the parent dialog to become excessively wide. pub(super) fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {     let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();     let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| {         let label = ui.label(label);         let mut v = text(d, key);-        if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() {+        if ui.add(egui::TextEdit::singleline(&mut v).desired_width(960.0)).labelled_by(label.id).changed() {             d.values.insert(key.into(), json!(v));         }     };     edit(ui, d, "range", "Chart data range:");     ui.add_space(6.0);     ui.strong("Legend Entries (Series)");-    let names: Vec<String> =-        d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default();-    let mut remove: Vec<u64> =-        d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default();-    let kept = names.len().saturating_sub(remove.len());-    egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| {-        for (i, name) in names.iter().enumerate() {-            let i = i as u64;-            let removed = remove.contains(&i);-            ui.horizontal(|ui| {-                let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() };-                ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) });-                if removed {-                    if ui.small_button("Keep").clicked() {-                        remove.retain(|x| *x != i);-                    }-                } else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() {-                    remove.push(i);-                }-            });+    ui.label("Use Select… to edit the legend title and the independent X/Y ranges for each curve.");+    let mut rows: Vec<(String, String, String)> = d+        .values+        .get("series")+        .and_then(Json::as_array)+        .map(|items| {+            items+                .iter()+                .map(|item| {+                    (+                        item.get("name").and_then(Json::as_str).unwrap_or("").to_string(),+                        item.get("x").and_then(Json::as_str).unwrap_or("").to_string(),+                        item.get("y").and_then(Json::as_str).unwrap_or("").to_string(),+                    )+                })+                .collect()+        })+        .unwrap_or_default();+    egui::Grid::new("chart_series_editor").striped(true).num_columns(5).spacing([6.0, 4.0]).show(ui, |ui| {+        ui.strong("Legend title");+        ui.strong("X values");+        ui.strong("Y values");+        ui.strong("");+        ui.strong("");+        ui.end_row();+        let mut remove = None;+        let mut edit = None;+        let can_remove = rows.len() > 1;+        for (i, (name, x, y)) in rows.iter_mut().enumerate() {+            ui.add_sized([150.0, 24.0], egui::Label::new(egui::RichText::new(name.as_str()).strong()));+            ui.add_sized([175.0, 24.0], egui::Label::new(egui::RichText::new(short_ref(x)).monospace()));+            ui.add_sized([175.0, 24.0], egui::Label::new(egui::RichText::new(short_ref(y)).monospace()));+            if ui.button("Select…").clicked() {+                edit = Some(i);+            }+            if ui.add_enabled(can_remove, egui::Button::new("Remove").small()).clicked() {+                remove = Some(i);+            }+            ui.end_row();+        }+        if let Some(i) = remove {+            rows.remove(i);+        }+        if let Some(i) = edit {+            d.values.insert("series".into(), json!(rows.iter().map(|(name, x, y)| json!({"name": name, "x": x, "y": y})).collect::<Vec<_>>()));+            let (name, x, y) = rows.get(i).cloned().unwrap_or_default();+            app.open_dialog("chartSeries", json!({"index": i, "name": name, "x": x, "y": y}));         }     });-    d.values.insert("remove".into(), json!(remove));-    ui.add_space(6.0);-    edit(ui, d, "categories", "Horizontal (Category) Axis Labels:");+    if ui.button("Add Series").clicked() {+        let i = rows.len();+        let name = format!("Series{}", i + 1);+        rows.push((name.clone(), String::new(), String::new()));+        app.open_dialog("chartSeries", json!({"index": i, "name": name, "x": "", "y": ""}));+    }+    let series: Vec<Json> = rows.iter().map(|(name, x, y)| json!({"name": name, "x": x, "y": y})).collect();+    d.values.insert("series".into(), json!(series));+    let uses_independent_xy = rows.len() > 1 && rows.iter().all(|(_, x, y)| !x.trim().is_empty() && !y.trim().is_empty());+    if !uses_independent_xy {+        ui.add_space(6.0);+        edit(ui, d, "categories", "Horizontal (Category) Axis Labels:");+    }     ok_cancel(ui, confirm, open);     if *confirm {         *confirm = false;         let mut params = serde_json::Map::new();         params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null));+        let series_changed = d.values.get("series") != d.values.get("series0");         let range_changed = text(d, "range") != text(d, "range0");-        if range_changed {+        if series_changed {+            params.insert("series".into(), d.values.get("series").cloned().unwrap_or_else(|| json!([])));+        } else if range_changed {             params.insert("range".into(), json!(text(d, "range")));-        } else if !remove.is_empty() {-            // A new range rebuilds the series; removals only apply to the current ones.+        }+        // Keep the pre-existing command path usable for automation and older callers that still+        // set the legacy `remove` field directly.+        if let Some(remove) = d.values.get("remove").and_then(Json::as_array).filter(|items| !items.is_empty()) {             params.insert("removeSeries".into(), json!(remove));         }-        if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) {+        if !series_changed && (text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty())) {             params.insert("categories".into(), json!(text(d, "categories")));         }         if params.len() == 1 {@@ -185,3 +228,87 @@ pub(super) fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog,         }     } }++fn short_ref(value: &str) -> String {+    const MAX: usize = 25;+    let value = value.strip_prefix('=').unwrap_or(value);+    if value.chars().count() <= MAX {+        return value.to_string();+    }+    let end: String = value.chars().skip(value.chars().count().saturating_sub(MAX - 1)).collect();+    format!("…{end}")+}++/// Edits one series at a time. While X or Y picking is active, clicks and drags on the sheet are+/// routed through Dialog::pick_reference, exactly like Excel's range selector.+pub(super) fn chart_series(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {+    ui.label("Legend title");+    let mut name = series_text(d, "name");+    if ui.add(egui::TextEdit::singleline(&mut name).desired_width(360.0)).changed() {+        d.values.insert("name".into(), json!(name));+    }+    ui.add_space(6.0);+    chart_series_field(ui, d, "x", "X values");+    chart_series_field(ui, d, "y", "Y values");+    ui.add_space(8.0);+    ok_cancel(ui, confirm, open);+    if *confirm {+        *confirm = false;+        if series_text(d, "y").trim().is_empty() {+            app.message = Some((d.title.clone(), "Y values cannot be empty.".into()));+            return;+        }+        let name = series_text(d, "name");+        let x = series_text(d, "x").trim_start_matches('=').to_string();+        let y = series_text(d, "y").trim_start_matches('=').to_string();+        if let Some(parent) = d.back.as_mut()+            && let Some(index) = d.values.get("index").and_then(Json::as_u64).and_then(|i| usize::try_from(i).ok())+            && let Some(series) = parent.values.get_mut("series").and_then(Json::as_array_mut)+            && let Some(row) = series.get_mut(index).and_then(Json::as_object_mut)+        {+            row.insert("name".into(), json!(name));+            row.insert("x".into(), json!(x));+            row.insert("y".into(), json!(y));+        }+        *open = false;+    }+}++fn series_text(d: &Dialog, key: &str) -> String {+    d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string()+}++fn chart_series_field(ui: &mut egui::Ui, d: &mut Dialog, key: &'static str, label: &str) {+    ui.label(label);+    ui.horizontal(|ui| {+        let mut value = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();+        let r = ui.add(egui::TextEdit::singleline(&mut value).desired_width(360.0));+        if r.changed() {+            d.values.insert(key.into(), json!(value));+        }+        if ui.button("Select…").clicked() {+            // Point mode is formula-based. Existing chart references are stored without the+            // leading `=`, so add it before handing the field to the shared range picker.+            if !value.trim().is_empty() && !value.trim_start().starts_with('=') {+                value = format!("={value}");+                d.values.insert(key.into(), json!(value.clone()));+            }+            let pointed = crate::editor::formula_refs(&value).last().map(|(start, end, ..)| (*end, Some((*start, *end))));+            let (caret, point) = pointed.unwrap_or((value.chars().count(), None));+            d.pick = Some(super::RefPick {+                key,+                // Chart series keep an explicit sheet qualifier, including for the active sheet.+                sheet: String::new(),+                caret,+                point,+                anchor: gridcraft_engine::core::CellRef::new(0, 0),+                refocus: false,+                applies_to: String::new(),+            });+        }+        let typed = r.changed();+        if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) {+            super::show::track_pick(pick, &r, typed);+        }+    });+}diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs--- a/crates/ui-egui/src/dialogs/show.rs+++ b/crates/ui-egui/src/dialogs/show.rs@@ -122,12 +122,14 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {         "solver" => 460.0,         "zoom" => 220.0,         "pasteSpecial" => 480.0,+        "selectData" => 760.0,+        "chartSeries" => 520.0,         _ => 380.0,     };     let window = egui::Window::new(app.ui.language.tr(&d.title))         .id(egui::Id::unique("dialog").with(&d.name))         .collapsible(false)-        .resizable(false)+        .resizable(d.name == "selectData" || d.name == "chartSeries")         .default_width(width)         // A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar.         .pivot(egui::Align2::CENTER_CENTER)@@ -154,6 +156,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {                 "manageRules" => manage_rules(app, ui),                 "recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open),                 "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),+                "chartSeries" => charts::chart_series(app, ui, &mut d, &mut confirm, &mut open),                 "commandSearch" => command_search(app, ui, &mut d, &mut confirm),                 "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),                 "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open),@@ -459,7 +462,7 @@ fn take_first_focus(d: &mut Dialog, r: &egui::Response) {  /// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a /// reference in it (the click took the focus), its caret after the reference.-fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) {+pub(super) fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) {     if typed {         pick.point = None;     }diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs--- a/crates/ui-egui/src/grid.rs+++ b/crates/ui-egui/src/grid.rs@@ -131,6 +131,12 @@ pub enum Drag {         orig: (CellRef, f32, f32, f32, f32),         resize: bool,     },+    /// Dragging a chart legend inside its chart.+    ChartLegend {+        id: u32,+        start: Pos2,+        orig: [f32; 2],+    }, }  /// The fill a left-drag of the handle just made, which the Auto Fill Options button can redodiff --git a/crates/ui-egui/src/grid/cursor.rs b/crates/ui-egui/src/grid/cursor.rs--- a/crates/ui-egui/src/grid/cursor.rs+++ b/crates/ui-egui/src/grid/cursor.rs@@ -49,7 +49,7 @@ fn drag_cursor(drag: &Drag) -> Option<CursorIcon> {         Drag::Select => Some(CursorIcon::Cell),         Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default),         Drag::Fill { .. } => Some(CursorIcon::Crosshair),-        Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move),+        Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } | Drag::ChartLegend { .. } => Some(CursorIcon::Move),         Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn),         Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow),         Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }),diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs--- a/crates/ui-egui/src/grid/interact.rs+++ b/crates/ui-egui/src/grid/interact.rs@@ -122,6 +122,13 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo                 app.grid.pane = Some("formatChart".into());             }             if primary_drag {+                if kind == "chart"+                    && let Some((legend_id, start, orig)) = crate::chartview::legend_drag_info(geo, sh, p)+                    && legend_id == id+                {+                    app.grid.drag = Drag::ChartLegend { id, start, orig };+                    return;+                }                 let a = object_anchor(sh, kind, id);                 let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS;                 if let Some(a) = a {@@ -516,6 +523,11 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo                 let _ = app.session.run("object.move", params);                 coalesce_last_undo(app);             }+            Drag::ChartLegend { id, start, orig } => {+                let d = (p - start) / geo.z.max(0.01);+                let _ = app.session.run("chart.set", json!({"chart": id, "legendPosition": [orig[0] + d.x, orig[1] + d.y]}));+                coalesce_last_undo(app);+            }             Drag::None => {}         }     }diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs--- a/crates/ui-egui/src/panes.rs+++ b/crates/ui-egui/src/panes.rs@@ -200,12 +200,20 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {             }         });     });+    ui.collapsing("Fonts & Text", |ui| {+        ui.label("Enter a font family name, or leave it empty to use the automatic font.");+        chart_text_style_row(app, ui, id, "General", "general", &chart.text.general, 10.0);+        chart_text_style_row(app, ui, id, "Chart title", "title", &chart.text.title, 14.0);+        chart_text_style_row(app, ui, id, "Axis numbers", "axisLabels", &chart.text.axis_labels, 9.0);+        chart_text_style_row(app, ui, id, "Axis titles", "axisTitles", &chart.text.axis_titles, 10.0);+        chart_text_style_row(app, ui, id, "Legend", "legend", &chart.text.legend, 9.0);+    });     ui.collapsing("Legend", |ui| {         ui.horizontal(|ui| {             for (l, v) in [("None", "none"), ("Bottom", "bottom"), ("Top", "top"), ("Left", "left"), ("Right", "right")] {                 let cur = format!("{:?}", chart.legend).eq_ignore_ascii_case(v);                 if ui.selectable_label(cur, l).clicked() {-                    app.run_or_alert("chart.set", json!({"chart": id, "legend": v}));+                    app.run_or_alert("chart.set", json!({"chart": id, "legend": v, "legendPosition": Json::Null}));                 }             }         });@@ -291,3 +299,33 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {         }     }); }++fn chart_text_style_row(+    app: &mut SheetApp,+    ui: &mut Ui,+    chart: u32,+    label: &str,+    key: &str,+    style: &gridcraft_engine::model::ChartTextStyle,+    fallback_size: f32,+) {+    ui.horizontal(|ui| {+        ui.label(label);+        let font_id = egui::Id::unique(("gridcraft.chart_font", chart, key));+        if let Some(font) = crate::widgets::committed_text(ui, font_id, style.font.as_deref().unwrap_or(""), None) {+            let mut role = serde_json::Map::new();+            role.insert("font".into(), if font.trim().is_empty() { Json::Null } else { json!(font.trim()) });+            let mut text = serde_json::Map::new();+            text.insert(key.into(), Json::Object(role));+            app.run_or_alert("chart.set", json!({"chart": chart, "text": text}));+        }+        let mut size = style.size.unwrap_or(fallback_size);+        if ui.add(egui::DragValue::new(&mut size).prefix("Size ").range(4.0..=72.0)).changed() {+            let mut role = serde_json::Map::new();+            role.insert("size".into(), json!(size));+            let mut text = serde_json::Map::new();+            text.insert(key.into(), Json::Object(role));+            app.run_or_alert("chart.set", json!({"chart": chart, "text": text}));+        }+    });+}diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs--- a/crates/xlsx/src/chart.rs+++ b/crates/xlsx/src/chart.rs@@ -274,6 +274,8 @@ pub fn read_chart(root: &El) -> Option<Chart> {         y_title: y_ax.and_then(|a| a.child("title")).and_then(rich_title),         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: root.path(&["pivotSource", "name"]).map(|n| strip_book(n.text.trim()).to_string()).filter(|n| !n.is_empty()),     }) }diff --git a/crates/xlsx/src/chartex.rs b/crates/xlsx/src/chartex.rs--- a/crates/xlsx/src/chartex.rs+++ b/crates/xlsx/src/chartex.rs@@ -136,6 +136,8 @@ pub fn read_chartex(root: &El) -> Option<Chart> {         y_title: axis_title("1"),         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: None,     }) }diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs--- a/crates/xlsx/src/tests/chart_book_names.rs+++ b/crates/xlsx/src/tests/chart_book_names.rs@@ -81,6 +81,8 @@ fn chart_names_are_written_qualified() {         y_title: None,         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: None,     });     wb.sheets = vec![Arc::new(s), Arc::new(Sheet::new("My Data"))];diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs--- a/crates/xlsx/src/tests/fixtures.rs+++ b/crates/xlsx/src/tests/fixtures.rs@@ -542,6 +542,8 @@ fn chartex_parts_as_excel_expects_them() {         y_title: None,         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: None,     });     wb.sheets = vec![std::sync::Arc::new(sheet)];diff --git a/crates/xlsx/src/tests/literal_category_roundtrip.rs b/crates/xlsx/src/tests/literal_category_roundtrip.rs--- a/crates/xlsx/src/tests/literal_category_roundtrip.rs+++ b/crates/xlsx/src/tests/literal_category_roundtrip.rs@@ -47,6 +47,8 @@ fn literal_category_with_separators_survives_save() {         y_title: None,         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: None,     });     wb.sheets = vec![Arc::new(s)];diff --git a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs--- a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs+++ b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs@@ -47,6 +47,8 @@ fn workbook(categories: &str, values: &str) -> Workbook {         y_title: None,         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: None,     });     Workbook { sheets: vec![Arc::new(s)], ..Workbook::default() }diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs--- a/crates/xlsx/src/tests/roundtrip.rs+++ b/crates/xlsx/src/tests/roundtrip.rs@@ -413,6 +413,8 @@ fn sample() -> Workbook {         y_title: Some("Amount".into()),         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: None,     };     for kind in [diff --git a/crates/xlsx/src/tests/smooth_roundtrip.rs b/crates/xlsx/src/tests/smooth_roundtrip.rs--- a/crates/xlsx/src/tests/smooth_roundtrip.rs+++ b/crates/xlsx/src/tests/smooth_roundtrip.rs@@ -35,6 +35,8 @@ fn smooth_line_setting_round_trips() {         y_title: None,         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: None,     });     let mut smooth_series = base_series.clone();@@ -53,6 +55,8 @@ fn smooth_line_setting_round_trips() {         y_title: None,         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: None,     });     wb.sheets = vec![Arc::new(s)];diff --git a/crates/xlsx/src/tests/trendline_roundtrip.rs b/crates/xlsx/src/tests/trendline_roundtrip.rs--- a/crates/xlsx/src/tests/trendline_roundtrip.rs+++ b/crates/xlsx/src/tests/trendline_roundtrip.rs@@ -59,6 +59,8 @@ fn chart(id: u32, kind: ChartKind, trendlines: Vec<Trendline>) -> Chart {         y_title: None,         source: None,         by_rows: false,+        text: Default::default(),+        legend_position: None,         pivot: None,     } }