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 redo
diff --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,
     }
 }
