From 735e16d93c33a9d480dfcf055c5373402fb1bdfb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:30:41 -0400 Subject: [PATCH] Charts: text at the sizes the file sets, as in PowerPoint (#358) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/model/src/chart.rs | 9 ++++++ crates/pptx/src/read/chart.rs | 52 +++++++++++++++++++++++++++++++++++ crates/render/src/chart.rs | 9 ++++-- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index e83e1ed..aebee49 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -150,6 +150,13 @@ pub struct Chart { pub legend: Option, /// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it. pub legend_overlay: bool, + /// Text size in points of the axis labels and legend (from their `c:txPr`, else the chart's); + /// `None`: the file gives none. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_size: Option, + /// Title text size in points (its rich text or `c:txPr`). + #[serde(skip_serializing_if = "Option::is_none")] + pub title_size: Option, pub data_labels: bool, pub gridlines: bool, pub axis_titles: (Option, Option), @@ -193,6 +200,8 @@ impl Default for Chart { series: vec![], legend: Some("b".into()), legend_overlay: false, + text_size: None, + title_size: None, data_labels: false, gridlines: true, axis_titles: (None, None), diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 8d2cb59..6d7541f 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -277,9 +277,35 @@ pub(crate) fn chart_from(root: &El) -> Option { if let Some(a) = cat_ax { c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); } + // Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to + // 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); + 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")))); Some(c) } +/// The size in points of the first `a:defRPr` or `a:rPr` with one under `e` (`sz` in hundredths). +fn text_size(e: Option<&El>) -> Option { + fn rec(e: &El, depth: usize) -> Option { + if depth > 8 { + return None; + } + for c in e.elements() { + if (c.is("defRPr") || c.is("rPr")) + && let Some(v) = c.attr("sz").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0) + { + return Some((v / 100.0).clamp(1.0, 400.0)); + } + if let Some(v) = rec(c, depth + 1) { + return Some(v); + } + } + None + } + rec(e?, 0) +} + #[cfg(test)] mod tests { use super::*; @@ -336,4 +362,30 @@ mod tests { let c = read_chart_xml(single.as_bytes()).unwrap(); assert!(c.series.iter().all(|s| !s.secondary_axis)); } + + #[test] + fn text_sizes_are_read_from_the_file() { + // PowerPoint's new charts set the axes' and legend's text to 11.97 pt and the title's to + // 18.62 pt; a chart that only sets its overall text size uses it everywhere. + let ns = + r#"xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#; + let tx = |sz: u32| format!(r#""#); + let xml = format!( + r#"T{}{}"#, + tx(1197), + tx(1197) + ); + let c = read_chart_xml(xml.as_bytes()).unwrap(); + assert_eq!((c.text_size, c.title_size), (Some(11.97), Some(18.62))); + let xml = format!( + r#"{}"#, + tx(1400) + ); + let c = read_chart_xml(xml.as_bytes()).unwrap(); + assert_eq!((c.text_size, c.title_size), (Some(14.0), None)); + let xml = format!( + r#""# + ); + assert_eq!(read_chart_xml(xml.as_bytes()).unwrap().text_size, None, "no sizes: the renderer picks one"); + } } diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index f8417aa..eb2b0a9 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -139,11 +139,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h rctx.pres, ); } - let base = (h.min(w) / 22.0).clamp(7.0, 18.0); + // The file's text sizes when it has them (PowerPoint draws chart text at them); otherwise a size + // that suits the chart. + let base = c.text_size.unwrap_or_else(|| (h.min(w) / 22.0).clamp(7.0, 18.0)); + let title_size = c.title_size.unwrap_or(base * 1.4); let mut area = Rect::new(w * 0.04, h * 0.04, w * 0.96, h * 0.96); if let Some(t) = &c.title { - label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + base * 1.8), base * 1.4, Align::Center, Anchor::Top, text_col, false); - area.y0 += base * 2.4; + label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), title_size, Align::Center, Anchor::Top, text_col, false); + area.y0 += title_size * 1.7; } let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); // Legend placed by `c:legendPos`.