Compare commits

...

1 Commits

Author SHA1 Message Date
Brandon Thomas 8afaf3f90f Charts: text at the sizes the file sets, as in PowerPoint
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 04:30:31 -04:00
3 changed files with 67 additions and 3 deletions

View File

@ -150,6 +150,13 @@ pub struct Chart {
pub legend: Option<String>, pub legend: Option<String>,
/// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it. /// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it.
pub legend_overlay: bool, 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<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, pub data_labels: bool,
pub gridlines: bool, pub gridlines: bool,
pub axis_titles: (Option<String>, Option<String>), pub axis_titles: (Option<String>, Option<String>),
@ -193,6 +200,8 @@ impl Default for Chart {
series: vec![], series: vec![],
legend: Some("b".into()), legend: Some("b".into()),
legend_overlay: false, legend_overlay: false,
text_size: None,
title_size: None,
data_labels: false, data_labels: false,
gridlines: true, gridlines: true,
axis_titles: (None, None), axis_titles: (None, None),

View File

@ -277,9 +277,35 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
if let Some(a) = cat_ax { if let Some(a) = cat_ax {
c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); 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) 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<f64> {
fn rec(e: &El, depth: usize) -> Option<f64> {
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::<f64>().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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -336,4 +362,30 @@ mod tests {
let c = read_chart_xml(single.as_bytes()).unwrap(); let c = read_chart_xml(single.as_bytes()).unwrap();
assert!(c.series.iter().all(|s| !s.secondary_axis)); 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#"<c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr sz="{sz}"/></a:pPr></a:p></c:txPr>"#);
let xml = format!(
r#"<c:chartSpace {ns}><c:chart><c:title><c:tx><c:rich><a:bodyPr/><a:p><a:pPr><a:defRPr sz="1862"/></a:pPr><a:r><a:t>T</a:t></a:r></a:p></c:rich></c:tx></c:title><c:plotArea><c:barChart><c:barDir val="col"/><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/>{}</c:catAx><c:valAx><c:axId val="2"/>{}</c:valAx></c:plotArea></c:chart></c:chartSpace>"#,
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#"<c:chartSpace {ns}><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea></c:chart>{}</c:chartSpace>"#,
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#"<c:chartSpace {ns}><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea></c:chart></c:chartSpace>"#
);
assert_eq!(read_chart_xml(xml.as_bytes()).unwrap().text_size, None, "no sizes: the renderer picks one");
}
} }

View File

@ -139,11 +139,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
rctx.pres, 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); let mut area = Rect::new(w * 0.04, h * 0.04, w * 0.96, h * 0.96);
if let Some(t) = &c.title { 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); 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 += base * 2.4; area.y0 += title_size * 1.7;
} }
let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut);
// Legend placed by `c:legendPos`. // Legend placed by `c:legendPos`.