Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 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
Brandon ThomasandClaude Opus 5.5 7d8bb05374 rustfmt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 04:20:52 -04:00
Магда М.Г. ce1c7b7759 Charts: axis number formats, automatic major unit, hidden axes
Value axes showed raw numbers ("800000") with a fixed ~5-interval scale and
fixed label gutters, deleted axes were still drawn, and data labels ignored
their number format, so Russian business charts read "516600000" where
PowerPoint shows "516 600 000 ₽".

- New deckcraft_model::numfmt: Excel number format codes (sections for
  positive/negative/zero, quoted and escaped literals, _x padding, *x fill,
  [$sym-lcid] currency, [colour]/conditions dropped, 0/#/? placeholders,
  grouping with ',' or the locale's literal space, trailing-comma scaling,
  %, General) with digit grouping and decimal mark from the chart's
  language (c:lang: ru-RU -> "800 000", "12,5%").
- Reading: each value axis' numFmt, c:delete, scaling min/max and
  majorUnit, for the primary and a combo chart's secondary axis; data
  labels' numFmt (series, then group, else the source data's formatCode);
  the chart's c:lang. The primary value axis is the one on the left
  (bottom) and the secondary the one on the right/top or crossing at max,
  whatever the plot groups' order.
- Scale (Chart::axis_scale): fixed bounds and unit where given, else the
  data plus 5% headroom rounded out to the smallest 1-2-5 unit that leaves
  as many intervals as the axis has room for labels, as PowerPoint does
  (0-800 000 by 100 000, 0-7 000 by 1 000 on the sample deck).
- Rendering: labels in the axis format, gutters sized to the longest label,
  deleted axes draw no labels and take no room, data labels in their own
  format.
2026-10-13 04:20:52 -04:00
Магда М.Г. 5c82c08847 Charts: draw combo charts with columns, lines and a secondary axis
A combo chart (columns plus lines, often with the lines on a second value
axis) was read with each series' type but drawn as lines only, on one
scale, so a spend-and-clicks chart showed the clicks as a flat line along
the bottom and no columns.

- Reading: a plot group whose value axis isn't the first group's is on the
  secondary axis (Series::secondary_axis, now set).
- Model: Chart::series_kind, on_secondary and axis_range (per axis; a combo
  stacks only its stacked series).
- Rendering: bar-like series are drawn as bars (clustered or stacked among
  themselves), the others as lines/areas over them, each against its own
  axis; the secondary axis' labels go on the right. Line series use their
  line's colour and width (where PowerPoint keeps them), also in the legend.
  Long category labels wrap onto two lines.
2026-10-13 04:20:52 -04:00
Brandon ThomasandClaude Opus 5.5 67b7f82cff Chart legend test: a chart with a real namespace and a bar chart, as the reader needs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 04:20:52 -04:00
MarekSpodBiedry b585027de2 Chart legend: honour legendPos and overlay in the renderer (#262) 2026-10-13 04:20:52 -04:00
3 changed files with 67 additions and 3 deletions
+9
View File
@@ -150,6 +150,13 @@ pub struct Chart {
pub legend: Option<String>,
/// `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<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 gridlines: bool,
pub axis_titles: (Option<String>, Option<String>),
@@ -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),
+52
View File
@@ -277,9 +277,35 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
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<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)]
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#"<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");
}
}
+6 -3
View File
@@ -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`.