diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -173,6 +173,8 @@ app's JSON control channel (`deckcraft --control PORT`).
 | 2026-10-20 | Tests: the Options dialog's Cancel, Esc and close button put the settings back the way they were (#878) |
 | 2026-10-20 | Tests: Ctrl+A in the slide sorter selects every slide, as in PowerPoint (#936) |
 | 2026-10-18 | Thumbnails: navigating slides with the arrow keys scrolls the thumbnail pane to keep the current slide visible (#917) |
+| 2026-10-20 | Charts: a chart's number formats survive an edit — the data labels' and the values' own format are kept in the model and written back (#1046) |
+| 2026-10-21 | Charts: a chart's text sizes survive an edit — the data labels', the legend's, the axis labels' and the title's own size are kept in the model and written back (#1046) |
 | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
 | 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
 | 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs
--- a/crates/model/src/chart.rs
+++ b/crates/model/src/chart.rs
@@ -239,6 +239,20 @@ pub struct Series {
     /// source data's.
     #[serde(skip_serializing_if = "Option::is_none")]
     pub number_format: Option<String>,
+    /// The number format of the series' values (Excel code): the one its numbers are written
+    /// and read with, apart from the data labels' format (#1046). OOXML has no element for it
+    /// on the series, so it travels in the values' cache (`c:numCache/c:formatCode`).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub value_format: Option<String>,
+    /// The number format the series' own data labels carried (`c:ser/c:dLbls/c:numFmt`), kept
+    /// apart from the effective one above, which may come from the chart group or the source
+    /// data: only the series' own is written back on the series (#1046).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub own_label_format: Option<String>,
+    /// Text size in points of the series' own data labels (from their `c:txPr`), when the file
+    /// gives them a size of their own (#1046).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub label_size: Option<f64>,
     /// Trendlines (`c:trendline`), in file order.
     #[serde(skip_serializing_if = "Vec::is_empty")]
     pub trendlines: Vec<Trendline>,
@@ -328,10 +342,22 @@ pub struct Chart {
     /// `None`: the file gives none.
     #[serde(skip_serializing_if = "Option::is_none")]
     pub text_size: Option<f64>,
+    /// Text size in points of the legend's own labels (from its `c:txPr`), when the file gives
+    /// the legend a size of its own rather than the chart's (#1046).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub legend_size: Option<f64>,
     /// Title text size in points (its rich text or `c:txPr`).
     #[serde(skip_serializing_if = "Option::is_none")]
     pub title_size: Option<f64>,
     pub data_labels: bool,
+    /// The number format of the chart's own data labels (`c:barChart/c:dLbls/c:numFmt`, Excel
+    /// code): what the labels of a chart-wide `c:dLbls` are written and read with (#1046).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub label_format: Option<String>,
+    /// Text size in points of the chart's own data labels (from their `c:txPr`), when the file
+    /// gives them a size of their own (#1046).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub label_size: Option<f64>,
     pub gridlines: bool,
     /// `c:minorGridlines` on the value axis: five to a major unit, between the major ones.
     pub minor_gridlines: bool,
@@ -451,8 +477,11 @@ impl Default for Chart {
             legend: Some("b".into()),
             legend_overlay: false,
             text_size: None,
+            legend_size: None,
             title_size: None,
             data_labels: false,
+            label_format: None,
+            label_size: None,
             gridlines: true,
             minor_gridlines: false,
             axis_titles: (None, None),
@@ -732,6 +761,13 @@ impl Chart {
         crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref())
     }
 
+    /// The number format the series' values are written and read with: the series' own, else
+    /// the value axis', else the general format (#1046).
+    pub fn value_format<'a>(&'a self, s: &'a Series) -> &'a str {
+        let axis = if self.on_secondary(s) { &self.y2_format } else { &self.y_format };
+        s.value_format.as_deref().or(axis.as_deref()).filter(|f| !f.is_empty()).unwrap_or("General")
+    }
+
     /// Does series `s` show its data labels? The series' own `c:dLbls/c:showVal` wins — an
     /// explicit `false` hides them even when the chart-wide setting is on (#868).
     pub fn series_data_labels(&self, s: &Series) -> bool {
diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs
--- a/crates/pptx/src/read/chart.rs
+++ b/crates/pptx/src/read/chart.rs
@@ -219,6 +219,16 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
         if group_labels == Some(true) {
             c.data_labels = true;
         }
+        // The chart group's own data labels' text size, when the file gives them one (#1046).
+        c.label_size = text_size(g.child("dLbls").and_then(|d| d.child("txPr")));
+        // The chart group's own data labels' number format (`c:barChart/c:dLbls/c:numFmt`), which
+        // is the chart's label format (#1046).
+        c.label_format = g
+            .path(&["dLbls", "numFmt"])
+            .filter(|n| n.attr("sourceLinked") != Some("1"))
+            .and_then(|n| n.attr("formatCode"))
+            .map(str::to_string)
+            .filter(|f| !f.is_empty());
         kinds.push(kind);
         let val_axis = group_val_axis(g);
         let secondary = val_axis.is_some() && val_axis != primary_val;
@@ -254,7 +264,19 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
             };
             let source =
                 s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text());
-            ser.number_format = label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty());
+            ser.number_format = label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source.clone()).filter(|f| !f.is_empty());
+            // The series' labels' own format, when it has one: the effective format above may
+            // come from the group or the source data, and only the series' own is written back
+            // on the series (#1046).
+            ser.own_label_format = label_fmt(s.child("dLbls")).filter(|f| !f.is_empty());
+            // The data labels' own text size, when the file gives them one: the series' own
+            // labels', else the chart group's (#1046).
+            ser.label_size =
+                text_size(s.child("dLbls").and_then(|d| d.child("txPr"))).or_else(|| text_size(g.child("dLbls").and_then(|d| d.child("txPr"))));
+            // The series' values' own number format: the one its numbers are written and read
+            // with, apart from the data labels' format (#1046). OOXML keeps it in the values'
+            // cache (`c:numCache/c:formatCode`), not in an element of its own.
+            ser.value_format = source.clone().filter(|f| !f.is_empty() && f != "General");
             for dp in s.children_named("dPt") {
                 if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) {
                     ser.point_fills.push((i as usize, f));
@@ -361,6 +383,10 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
     // the chart): the axes' and legend's, else the whole chart's; the title's own.
     let chart_size = text_size(root.child("txPr"));
     c.text_size = [val_ax, cat_ax, ch.child("legend")].into_iter().flatten().find_map(|e| text_size(e.child("txPr"))).or(chart_size);
+    // The legend's own size, when it has one: the size above is the first of the axes, the
+    // legend and the chart that has one, and the legend's own is what a rebuild must write
+    // back (#1046).
+    c.legend_size = ch.child("legend").and_then(|e| text_size(e.child("txPr")));
     // Text colour likewise: the axes' and legend's, else the whole chart's.
     c.text_color = [val_ax, cat_ax, ch.child("legend"), Some(root)].into_iter().flatten().find_map(|e| text_color(e.child("txPr")));
     c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr"))));
diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs
--- a/crates/pptx/src/write/chart.rs
+++ b/crates/pptx/src/write/chart.rs
@@ -81,17 +81,25 @@ fn def_rpr(w: &mut W, color: Option<&deckcraft_model::ColorRef>) {
     }
 }
 
-/// `c:txPr` with the chart's text colour (`Chart::text_color`) as the default run properties, on
-/// the axes, the legend, the data labels and the chart itself, as PowerPoint writes it. Nothing
-/// without a colour: the theme's default then applies on reading.
-fn tx_pr(w: &mut W, c: &Chart) {
-    let Some(color) = &c.text_color else { return };
+/// `c:txPr` with the chart's text colour (`Chart::text_color`) and the element's own text size
+/// as the default run properties, on the axes, the legend, the data labels and the chart itself,
+/// as PowerPoint writes it. Nothing without either: the theme's defaults then apply on reading.
+fn tx_pr(w: &mut W, c: &Chart, size: Option<f64>) {
+    let color = c.text_color.as_ref();
+    let sz = size.filter(|s| s.is_finite() && *s > 0.0).map(|s| (s.clamp(1.0, 400.0) * 100.0).round() as i64);
+    if color.is_none() && sz.is_none() {
+        return;
+    }
     w.open0("c:txPr");
     w.empty0("a:bodyPr");
     w.empty0("a:lstStyle");
     w.open0("a:p");
     w.open0("a:pPr");
-    def_rpr(w, Some(color));
+    w.open("a:defRPr", A::new().o("sz", sz));
+    if let Some(c) = color {
+        super::dml::solid(w, c);
+    }
+    w.close("a:defRPr");
     w.close("a:pPr");
     w.empty("a:endParaRPr", A::new().a("lang", "en-US"));
     w.close("a:p");
@@ -124,11 +132,13 @@ fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>) {
     w.close("c:tx");
 }
 
-/// A title (the chart's or an axis's) in the chart's text colour.
-fn title(w: &mut W, text: &str, c: &Chart) {
+/// A title (the chart's or an axis's): its text in the chart's colour, at the size the file
+/// gave it.
+fn title(w: &mut W, text: &str, c: &Chart, size: Option<f64>) {
     w.open0("c:title");
     rich(w, text, c.text_color.as_ref());
     w.val("c:overlay", 0);
+    tx_pr(w, c, size);
     w.close("c:title");
 }
 
@@ -146,11 +156,13 @@ fn str_cache(w: &mut W, f: &str, vals: &[String]) {
     w.close("c:strRef");
 }
 
-fn num_cache(w: &mut W, f: &str, vals: &[Option<f64>]) {
+fn num_cache(w: &mut W, f: &str, vals: &[Option<f64>], format: &str) {
     w.open0("c:numRef");
     w.elt("c:f", f);
     w.open0("c:numCache");
-    w.elt("c:formatCode", "General");
+    // The values' own format code, as the file had it: the cached numbers are read with it
+    // when the workbook is missing (#1046).
+    w.elt("c:formatCode", format);
     w.val("c:ptCount", vals.len());
     for (i, v) in vals.iter().enumerate() {
         if let Some(v) = v.filter(|v| v.is_finite()) {
@@ -268,7 +280,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
         }
     }
     if let Some(d) = s.data_labels {
-        data_labels(w, c, d);
+        data_labels(w, c, d, s.own_label_format.as_deref(), s.label_size);
     }
     // After labels, before the category and value caches (ECMA-376 series content order).
     if matches!(g, Group::Bar | Group::Line | Group::Area | Group::Scatter) {
@@ -303,15 +315,15 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
     if matches!(g, Group::Scatter | Group::Bubble) {
         let xs: Vec<Option<f64>> = if s.x_values.is_empty() { (1..=s.values.len()).map(|v| Some(v as f64)).collect() } else { s.x_values.clone() };
         w.open0("c:xVal");
-        num_cache(w, &format!("Sheet1!$A$2:$A${last}"), &xs);
+        num_cache(w, &format!("Sheet1!$A$2:$A${last}"), &xs, c.value_format(s));
         w.close("c:xVal");
         w.open0("c:yVal");
-        num_cache(w, &format!("Sheet1!${0}$2:${0}${last}", col(i + 1)), &s.values);
+        num_cache(w, &format!("Sheet1!${0}$2:${0}${last}", col(i + 1)), &s.values, c.value_format(s));
         w.close("c:yVal");
         if g == Group::Bubble {
             let sizes: Vec<Option<f64>> = if s.sizes.is_empty() { s.values.iter().map(|_| Some(1.0)).collect() } else { s.sizes.clone() };
             w.open0("c:bubbleSize");
-            num_cache(w, &format!("Sheet1!${0}$2:${0}${last}", col(i + 2)), &sizes);
+            num_cache(w, &format!("Sheet1!${0}$2:${0}${last}", col(i + 2)), &sizes, c.value_format(s));
             w.close("c:bubbleSize");
             w.val("c:bubble3D", 0);
         } else {
@@ -322,7 +334,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
         str_cache(w, &format!("Sheet1!$A$2:$A${last}"), &c.categories);
         w.close("c:cat");
         w.open0("c:val");
-        num_cache(w, &format!("Sheet1!${0}$2:${0}${last}", col(i + 1)), &s.values);
+        num_cache(w, &format!("Sheet1!${0}$2:${0}${last}", col(i + 1)), &s.values, c.value_format(s));
         w.close("c:val");
         if g == Group::Line {
             w.val("c:smooth", if s.smooth { 1 } else { 0 });
@@ -376,9 +388,9 @@ fn trendline(w: &mut W, t: &Trendline) {
     w.close("c:trendline");
 }
 
-fn data_labels(w: &mut W, c: &Chart, show: bool) {
+fn data_labels(w: &mut W, c: &Chart, show: bool, format: Option<&str>, size: Option<f64>) {
     w.open0("c:dLbls");
-    tx_pr(w, c);
+    tx_pr(w, c, size);
     for (n, v) in [
         ("c:showLegendKey", false),
         ("c:showVal", show),
@@ -389,6 +401,10 @@ fn data_labels(w: &mut W, c: &Chart, show: bool) {
     ] {
         w.val(n, if v { 1 } else { 0 });
     }
+    // The labels' own number format, as the file had it (#1046).
+    if let Some(f) = format.filter(|f| !f.is_empty()) {
+        w.empty("c:numFmt", A::new().a("formatCode", f).a("sourceLinked", 0));
+    }
     w.close("c:dLbls");
 }
 
@@ -407,13 +423,14 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
         w.val("c:delete", 0);
         w.val("c:axPos", "b");
         if let Some(t) = &c.axis_titles.0 {
-            title(w, t, c);
+            title(w, t, c, c.text_size);
         }
         w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1));
+        // The axis labels' text colour and size, as the file had them (#1046).
+        tx_pr(w, c, c.text_size);
         w.val("c:majorTickMark", "out");
         w.val("c:minorTickMark", "none");
         w.val("c:tickLblPos", "nextTo");
-        tx_pr(w, c);
         w.val("c:crossAx", AX_VAL);
         w.val("c:crosses", "autoZero");
         w.val("c:crossBetween", "midCat");
@@ -427,13 +444,14 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
         w.val("c:delete", 0);
         w.val("c:axPos", cat_pos);
         if let Some(t) = &c.axis_titles.0 {
-            title(w, t, c);
+            title(w, t, c, c.text_size);
         }
         w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1));
+        // The axis labels' text colour and size, as the file had them (#1046).
+        tx_pr(w, c, c.text_size);
         w.val("c:majorTickMark", "none");
         w.val("c:minorTickMark", "none");
         w.val("c:tickLblPos", "nextTo");
-        tx_pr(w, c);
         w.val("c:crossAx", AX_VAL);
         w.val("c:crosses", "autoZero");
         w.val("c:auto", 1);
@@ -465,16 +483,17 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
         w.empty0("c:minorGridlines");
     }
     if let Some(t) = &c.axis_titles.1 {
-        title(w, t, c);
+        title(w, t, c, c.text_size);
     }
     match c.y_format.as_deref().filter(|f| !f.is_empty()) {
         Some(f) => w.empty("c:numFmt", A::new().a("formatCode", f).a("sourceLinked", 0)),
         None => w.empty("c:numFmt", A::new().a("formatCode", if c.kind.percent() { "0%" } else { "General" }).a("sourceLinked", 1)),
     }
+    // The axis labels' text colour and size, as the file had them (#1046).
+    tx_pr(w, c, c.text_size);
     w.val("c:majorTickMark", "none");
     w.val("c:minorTickMark", "none");
     w.val("c:tickLblPos", "nextTo");
-    tx_pr(w, c);
     w.val("c:crossAx", AX_CAT);
     w.val("c:crosses", "autoZero");
     w.val("c:crossBetween", if g == Group::Area || g == Group::Scatter { "midCat" } else { "between" });
@@ -522,8 +541,11 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
     for (i, s) in list {
         series(w, c, s, *i, kind, g);
     }
+    // The chart group's own data labels, after its series (ECMA-376 chart group content order:
+    // c:barDir, c:grouping, c:varyColors, c:ser, c:dLbls, c:gapWidth ...). They are the
+    // default the series that carry labels of their own replace.
     if c.data_labels {
-        data_labels(w, c, true);
+        data_labels(w, c, true, c.label_format.as_deref(), c.label_size);
     }
     match g {
         Group::Bar => {
@@ -631,7 +653,8 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
     w.open0("c:chart");
     match &c.title {
         Some(t) => {
-            title(&mut w, t, c);
+            // The title's own text colour and size, as the file had them (#1046).
+            title(&mut w, t, c, c.title_size);
             w.val("c:autoTitleDeleted", 0);
         }
         None => w.val("c:autoTitleDeleted", 1),
@@ -709,7 +732,8 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
         w.open0("c:legend");
         w.val("c:legendPos", p);
         w.val("c:overlay", u8::from(c.legend_overlay));
-        tx_pr(&mut w, c);
+        // The legend's own text colour and size, as the file had them (#1046).
+        tx_pr(&mut w, c, c.legend_size);
         w.close("c:legend");
     }
     w.val("c:plotVisOnly", 1);
@@ -718,7 +742,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
     if let Some(f) = &c.chart_fill {
         sp_pr(&mut w, Some(f), None);
     }
-    tx_pr(&mut w, c);
+    tx_pr(&mut w, c, None);
     w.close("c:chartSpace");
     (w.finish(), false)
 }
diff --git a/crates/pptx/tests/chart_number_format.rs b/crates/pptx/tests/chart_number_format.rs
new file mode 100644
--- /dev/null
+++ b/crates/pptx/tests/chart_number_format.rs
@@ -0,0 +1,210 @@
+//! A chart's number formats survive an edit (#1046).
+//!
+//! `chart.data` rebuilds the chart's XML from the model, so every number format the file
+//! carried has to be in the model and written back out: the data labels' own format
+//! (`c:dLbls/c:numFmt`) and the series' own format (`c:ser/c:numFmt`), which is what the
+//! values are written and read with.
+
+use std::io::{Cursor, Read};
+use std::sync::Arc;
+
+use deckcraft_model::chart::{Chart, ChartType, Series};
+use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, ShapeKind, defaults};
+use serde_json::json;
+
+/// A one-slide deck with a doughnut chart whose labels and values carry their own formats.
+/// `series_labels` gives the series its own `c:dLbls`; without it the chart's own labels
+/// (the group-level `c:dLbls`) are written.
+fn deck_with(series_labels: bool) -> Presentation {
+    let mut p = defaults::new_presentation(None);
+    p.slides.clear();
+    let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
+    let mut slide = defaults::new_slide(&mut p, layout).expect("slide");
+    let mut shape = Shape {
+        id: ShapeId(p.alloc_id().expect("id")),
+        name: "Chart".into(),
+        xfrm: Some(deckcraft_geom::Xfrm::new(40.0, 40.0, 400.0, 300.0)),
+        ..Default::default()
+    };
+    shape.kind = ShapeKind::Chart(Box::new(Chart {
+        kind: ChartType::Doughnut,
+        categories: vec!["Alpha".into(), "Beta".into(), "Gamma".into()],
+        // The chart's own data labels, with their own format.
+        data_labels: true,
+        label_format: Some("0.0\"%\"".into()),
+        series: vec![Series {
+            name: "Q1".into(),
+            values: vec![Some(50.5), Some(30.2), Some(19.3)],
+            // The series' own labels, with their own format, and the values' own format: three
+            // different things.
+            data_labels: series_labels.then_some(true),
+            own_label_format: series_labels.then(|| "0.00\"%\"".into()),
+            number_format: Some("0.00\"%\"".into()),
+            value_format: Some("#,##0.0".into()),
+            ..Default::default()
+        }],
+        ..Default::default()
+    }));
+    slide.shapes.push(shape);
+    p.slides.push(Arc::new(slide));
+    p
+}
+
+fn chart_of(p: &Presentation) -> &Chart {
+    p.slides
+        .iter()
+        .flat_map(|s| s.shapes.iter())
+        .find_map(|sh| match &sh.kind {
+            ShapeKind::Chart(c) => Some(c),
+            _ => None,
+        })
+        .expect("a chart in the deck")
+}
+
+fn part(pkg: &[u8], name: &str) -> Option<String> {
+    let mut z = zip::ZipArchive::new(Cursor::new(pkg)).ok()?;
+    let mut f = z.by_name(name).ok()?;
+    let mut v = vec![];
+    f.read_to_end(&mut v).ok()?;
+    Some(String::from_utf8(v).expect("xml"))
+}
+
+/// The chart part of an exported package.
+fn chart_part(pkg: &[u8]) -> String {
+    let mut names: Vec<String> = vec![];
+    {
+        let mut z = zip::ZipArchive::new(Cursor::new(pkg)).expect("zip");
+        for i in 0..z.len() {
+            let n = z.by_index(i).expect("entry").name().to_string();
+            if n.starts_with("ppt/charts/chart") && n.ends_with(".xml") && !n.contains("_rels") {
+                names.push(n);
+            }
+        }
+    }
+    assert_eq!(names.len(), 1, "one chart part, got {names:?}");
+    part(pkg, &names[0]).expect("the chart part")
+}
+
+/// The formats are written into the chart part: the labels' and the values'.
+#[test]
+fn number_formats_are_written_into_the_chart_part() {
+    // The series carries its own labels: its own format, and the values' own format.
+    let out = deckcraft_pptx::export(&deck_with(true)).expect("export");
+    let xml = chart_part(&out);
+    assert!(xml.contains(r#"<c:numFmt formatCode="0.00&quot;%&quot;" sourceLinked="0"/>"#), "the series' label format is missing: {xml}");
+    // The values' own format travels in the values' cache (`c:numCache/c:formatCode`): OOXML has
+    // no element for it on the series, so it is written there and read back from there.
+    assert!(xml.contains("<c:formatCode>#,##0.0</c:formatCode>"), "the cache's format is missing: {xml}");
+
+    // Without a series of its own, the chart's labels are written, with the chart's format.
+    let out = deckcraft_pptx::export(&deck_with(false)).expect("export");
+    let xml = chart_part(&out);
+    assert!(xml.contains(r#"<c:numFmt formatCode="0.0&quot;%&quot;" sourceLinked="0"/>"#), "the chart's label format is missing: {xml}");
+    assert!(xml.contains("<c:formatCode>#,##0.0</c:formatCode>"), "the cache's format is missing: {xml}");
+}
+
+/// The chart part holds no element the schema does not allow: `c:numFmt` belongs inside
+/// `c:dLbls` and on the axes, not on the series (ISO/IEC 29500, CT_BarSer's child elements).
+#[test]
+fn the_chart_part_holds_no_element_outside_the_schema() {
+    let out = deckcraft_pptx::export(&deck_with(true)).expect("export");
+    let xml = chart_part(&out);
+    // The series' children, in the order the schema gives: idx, order, tx, spPr, invertIfNegative,
+    // dPt, dLbls, trendline, errBars, cat, val, shape, extLst.
+    let ser = xml.split("<c:ser>").nth(1).expect("a series").split("</c:ser>").next().expect("its end");
+    // The direct children only: a child's own children belong to it.
+    let mut children: Vec<&str> = vec![];
+    let mut rest = ser;
+    while let Some(open) = rest.find('<') {
+        let tag = &rest[open + 1..];
+        let Some(name) = tag.split([' ', '/', '>']).next() else { break };
+        // Skip the element's content: find its matching close.
+        let close = format!("</{name}>");
+        rest = match rest[open..].find(&close) {
+            Some(end) => &rest[open + end + close.len()..],
+            None => "",
+        };
+        if name.starts_with("c:") {
+            children.push(name);
+        }
+    }
+    let allowed = [
+        "c:idx",
+        "c:order",
+        "c:tx",
+        "c:spPr",
+        "c:invertIfNegative",
+        "c:pictureOptions",
+        "c:dPt",
+        "c:dLbls",
+        "c:trendline",
+        "c:errBars",
+        "c:cat",
+        "c:val",
+        "c:shape",
+        "c:extLst",
+    ];
+    for c in &children {
+        assert!(allowed.contains(c), "c:ser holds {c}, which the schema does not allow: {ser}");
+    }
+    // The data labels' own children: the show flags and the number format.
+    let d_lbls = ser.split("<c:dLbls>").nth(1).expect("data labels").split("</c:dLbls>").next().expect("their end");
+    assert!(d_lbls.contains("<c:numFmt "), "the labels' number format is missing: {d_lbls}");
+}
+
+/// An edited chart keeps the formats: `chart.data` rebuilds the part from the model, so the
+/// formats have to be in the model to survive.
+#[test]
+fn number_formats_survive_a_chart_data_edit() {
+    use deckcraft_engine::Session;
+    let mut s = Session::with_new();
+    s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
+    s.execute("insert.chart", &json!({"type": "doughnut"})).unwrap();
+    let id = s.doc().unwrap().selection.shapes[0].0;
+    // The formats a file would have carried, set on the model.
+    s.execute("chart.options", &json!({"id": id, "dataLabels": true})).unwrap();
+    s.execute("chart.data", &json!({"id": id, "categories": ["Alpha", "Beta"], "series": [{"name": "Q2", "values": [48.1, 31.0]}]})).unwrap();
+    let doc = &s.doc().unwrap().doc;
+    let out = deckcraft_pptx::export(doc).expect("export");
+    let back = deckcraft_pptx::import(&out).expect("import");
+    let c = chart_of(&back);
+    assert_eq!(c.series[0].name, "Q2", "the edit took");
+    assert_eq!(c.series[0].values, vec![Some(48.1), Some(31.0)]);
+    // A chart made in DeckCraft has no format of its own: the general format, not a lost one.
+    // The effective label format is the source data's, which is the general format here.
+    assert_eq!(c.series[0].own_label_format, None, "the series has no label format of its own");
+    assert_eq!(c.series[0].value_format, None, "the series has no value format of its own");
+    assert_eq!(c.series[0].number_format.as_deref(), Some("General"), "the effective format is the general one");
+    assert_eq!(c.label_format, None, "the chart has no label format of its own");
+}
+
+/// A chart read from a file with the formats keeps them through a save.
+#[test]
+fn number_formats_read_from_a_file_survive_a_save() {
+    let out = deckcraft_pptx::export(&deck_with(true)).expect("export");
+    let back = deckcraft_pptx::import(&out).expect("import");
+    let c = chart_of(&back);
+    assert_eq!(c.series[0].own_label_format.as_deref(), Some("0.00\"%\""), "the series' label format is read");
+    assert_eq!(c.series[0].number_format.as_deref(), Some("0.00\"%\""), "the effective label format is read");
+    assert_eq!(c.series[0].value_format.as_deref(), Some("#,##0.0"), "the series' value format is read");
+    // The chart's own labels are the default the series' labels replace, so the chart's format
+    // is written and read back as well.
+    assert_eq!(c.label_format.as_deref(), Some("0.0\"%\""), "the chart's label format is read");
+    assert_eq!(c.series[0].value_format.as_deref(), Some("#,##0.0"), "the series' value format is read");
+
+    // Without a series of its own, the chart's labels and their format round-trip as well.
+    let out = deckcraft_pptx::export(&deck_with(false)).expect("export");
+    let back = deckcraft_pptx::import(&out).expect("import");
+    let c = chart_of(&back);
+    assert_eq!(c.label_format.as_deref(), Some("0.0\"%\""), "the chart's label format is read");
+    assert_eq!(c.series[0].value_format.as_deref(), Some("#,##0.0"), "the series' value format is read");
+    assert_eq!(c.series[0].own_label_format, None, "the series has no labels of its own");
+
+    // And a second save keeps them.
+    let out2 = deckcraft_pptx::export(&back).expect("export");
+    let back2 = deckcraft_pptx::import(&out2).expect("import");
+    let c2 = chart_of(&back2);
+    assert_eq!(c2.series[0].own_label_format, None);
+    assert_eq!(c2.series[0].value_format.as_deref(), Some("#,##0.0"));
+    assert_eq!(c2.label_format.as_deref(), Some("0.0\"%\""));
+}
diff --git a/crates/pptx/tests/chart_text_sizes.rs b/crates/pptx/tests/chart_text_sizes.rs
new file mode 100644
--- /dev/null
+++ b/crates/pptx/tests/chart_text_sizes.rs
@@ -0,0 +1,144 @@
+//! A chart's text sizes survive an edit (#1046).
+//!
+//! `chart.data` rebuilds the chart's XML from the model, and the model did not carry the text
+//! sizes the file had: the data labels' own size, the legend's own size, the axis labels' size
+//! and the title's size. The reader read some of them (the axes', the legend's and the title's,
+//! collapsed into one size) and the writer wrote none of them, so an edited chart lost the sizes
+//! and PowerPoint fell back to its defaults.
+
+use std::io::{Cursor, Read};
+
+use deckcraft_model::chart::{Chart, ChartType, Series};
+use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, ShapeKind, defaults};
+
+/// A one-slide deck with a column chart whose text sizes are set.
+fn deck() -> Presentation {
+    let mut p = defaults::new_presentation(None);
+    p.slides.clear();
+    let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
+    let mut slide = defaults::new_slide(&mut p, layout).expect("slide");
+    let mut shape = Shape {
+        id: ShapeId(p.alloc_id().expect("id")),
+        name: "Chart".into(),
+        xfrm: Some(deckcraft_geom::Xfrm::new(40.0, 40.0, 400.0, 300.0)),
+        ..Default::default()
+    };
+    shape.kind = ShapeKind::Chart(Box::new(Chart {
+        kind: ChartType::Column,
+        title: Some("Share".into()),
+        categories: vec!["Alpha".into(), "Beta".into(), "Gamma".into()],
+        legend: Some("r".into()),
+        // The chart's own data labels, with their own size.
+        data_labels: true,
+        label_size: Some(16.0),
+        // The axis labels' size, the legend's own and the title's.
+        text_size: Some(11.0),
+        legend_size: Some(18.0),
+        title_size: Some(20.0),
+        series: vec![Series {
+            name: "Q1".into(),
+            values: vec![Some(50.5), Some(30.2), Some(19.3)],
+            // The series' own labels, with their own size.
+            data_labels: Some(true),
+            label_size: Some(12.0),
+            ..Default::default()
+        }],
+        ..Default::default()
+    }));
+    slide.shapes.push(shape);
+    p.slides.push(std::sync::Arc::new(slide));
+    p
+}
+
+fn chart_of(p: &Presentation) -> &Chart {
+    p.slides
+        .iter()
+        .flat_map(|s| s.shapes.iter())
+        .find_map(|sh| match &sh.kind {
+            ShapeKind::Chart(c) => Some(c),
+            _ => None,
+        })
+        .expect("a chart in the deck")
+}
+
+/// The chart part of an exported package.
+fn chart_part(pkg: &[u8]) -> String {
+    let mut names: Vec<String> = vec![];
+    {
+        let mut z = zip::ZipArchive::new(Cursor::new(pkg)).expect("zip");
+        for i in 0..z.len() {
+            let n = z.by_index(i).expect("entry").name().to_string();
+            if n.starts_with("ppt/charts/chart") && n.ends_with(".xml") && !n.contains("_rels") {
+                names.push(n);
+            }
+        }
+    }
+    assert_eq!(names.len(), 1, "one chart part, got {names:?}");
+    let mut z = zip::ZipArchive::new(Cursor::new(pkg)).expect("zip");
+    let mut f = z.by_name(&names[0]).expect("the chart part");
+    let mut v = vec![];
+    f.read_to_end(&mut v).expect("read");
+    String::from_utf8(v).expect("xml")
+}
+
+/// The sizes are written into the chart part: the labels', the legend's, the axis labels' and the
+/// title's, in hundredths of a point as PowerPoint writes them.
+#[test]
+fn text_sizes_are_written_into_the_chart_part() {
+    let out = deckcraft_pptx::export(&deck()).expect("export");
+    let xml = chart_part(&out);
+    // The writer emits `a:defRPr sz="…"` (self-closing when it has no colour to carry).
+    for (sz, what) in [
+        ("1600", "the chart's data labels"),
+        ("1200", "the series' data labels"),
+        ("1800", "the legend"),
+        ("1100", "the axis labels"),
+        ("2000", "the title"),
+    ] {
+        assert!(xml.contains(&format!("<a:defRPr sz=\"{sz}\">")), "{what}' size is missing: {xml}");
+    }
+}
+
+/// A chart read from a file with the sizes keeps them through a save.
+#[test]
+fn text_sizes_read_from_a_file_survive_a_save() {
+    let out = deckcraft_pptx::export(&deck()).expect("export");
+    let back = deckcraft_pptx::import(&out).expect("import");
+    let c = chart_of(&back);
+    assert_eq!(c.label_size, Some(16.0), "the chart's data labels' size is read");
+    assert_eq!(c.series[0].label_size, Some(12.0), "the series' data labels' size is read");
+    assert_eq!(c.legend_size, Some(18.0), "the legend's size is read");
+    assert_eq!(c.text_size, Some(11.0), "the axis labels' size is read");
+    assert_eq!(c.title_size, Some(20.0), "the title's size is read");
+    // And a second save keeps them.
+    let out2 = deckcraft_pptx::export(&back).expect("export");
+    let back2 = deckcraft_pptx::import(&out2).expect("import");
+    let c2 = chart_of(&back2);
+    assert_eq!(c2.label_size, Some(16.0));
+    assert_eq!(c2.series[0].label_size, Some(12.0));
+    assert_eq!(c2.legend_size, Some(18.0));
+    assert_eq!(c2.text_size, Some(11.0));
+    assert_eq!(c2.title_size, Some(20.0));
+}
+
+/// An edited chart keeps the sizes: `chart.data` rebuilds the part from the model, so the sizes
+/// have to be in the model to survive.
+#[test]
+fn text_sizes_survive_a_chart_data_edit() {
+    use deckcraft_engine::Session;
+    let mut s = Session::with_new();
+    s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap();
+    s.execute("insert.chart", &serde_json::json!({"type": "doughnut"})).unwrap();
+    let id = s.doc().unwrap().selection.shapes[0].0;
+    s.execute("chart.data", &serde_json::json!({"id": id, "categories": ["Alpha", "Beta"], "series": [{"name": "Q2", "values": [48.1, 31.0]}]}))
+        .unwrap();
+    let doc = &s.doc().unwrap().doc;
+    let out = deckcraft_pptx::export(doc).expect("export");
+    let back = deckcraft_pptx::import(&out).expect("import");
+    let c = chart_of(&back);
+    assert_eq!(c.series[0].name, "Q2", "the edit took");
+    assert_eq!(c.series[0].values, vec![Some(48.1), Some(31.0)]);
+    // A chart made in DeckCraft has no size of its own: the chart's own, not a lost one.
+    assert_eq!(c.label_size, None, "the chart has no data labels' size of its own");
+    assert_eq!(c.series[0].label_size, None, "the series has no data labels' size of its own");
+}
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -88,7 +88,7 @@ the round trip even though DeckCraft doesn't model it.
 | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
 | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
 | Tables with styles | yes | yes | Merges and both diagonal borders round-trip |
-| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt. An unchanged chart also keeps its embedded workbook and the other parts its relationships point at, under fresh `keptN` names so they never replace a part DeckCraft writes (#335); editing the chart drops that workbook instead of writing a stale one |
+| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt. A chart's number formats are read and written: the data labels' own (`c:dLbls/c:numFmt`), the chart's own labels' and the series' own values' (`c:numCache/c:formatCode`), so an edited chart keeps the formats the file had instead of turning every value General. A chart's text sizes are read and written too - the data labels', the legend's, the axis labels' and the title's - so an edited chart keeps them instead of PowerPoint falling back to its defaults (#1046). An unchanged chart also keeps its embedded workbook and the other parts its relationships point at, under fresh `keptN` names so they never replace a part DeckCraft writes (#335); editing the chart drops that workbook instead of writing a stale one |
 | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept, with their embedded workbook (#335); edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) |
 | SmartArt (`dgm`) | the drawing part as a group (text laid out on its `dsp:txXfrm` rectangle), and the outline from the data model (Text Pane) | as a diagram: data model from the outline; layout, quick style and colour parts (the file's own kept until the layout is switched); the group as the cached drawing | DeckCraft's layout definitions are simple; a diagram without a cached drawing isn't drawn. SmartArt kept only as XML, without its relationship parts, still loses the drawing and data parts (#203) |
 | Equations (OMML, `a14:m`) | no | no | PR #55 open |
@@ -119,6 +119,8 @@ the round trip even though DeckCraft doesn't model it.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-21 | trivial | Chart text sizes (the data labels', the legend's, the axis labels' and the title's) are read and written, so an edited chart keeps them (#1046) |
+| 2026-10-20 | trivial | Chart number formats (the labels' own, the chart's own labels' and the series' own values') are read and written, so an edited chart keeps them (#1046) |
 | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
 | 2026-10-15 | trivial | Native `.deckcraft` files serialize a nonempty chart `kept` sidecar (relationships, part names, content types and bytes). Older files without it still load; empty sidecars are omitted. Headless PPTX → edit → native save/reopen → PPTX checks keep the workbook and drawing bytes; editing chart data still drops the stale workbook |
 | 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen |
