Charts: bars draw their series' pattern fill and no fill

A bar series' own fill was read only when solid or a gradient: a pattern
(a:pattFill) was dropped, and an a:noFill series was read but drawn in the
palette's colour anyway, because the chart renderer turned every fill into
one colour and fell back to the palette for anything but solid. PowerPoint
for Mac draws the pattern, leaves an a:noFill bar empty and draws its
outline (c:spPr/a:ln) where it has one: a synthetic column chart with a
red-on-yellow dkDnDiag series, an empty series with a 3 pt blue outline
and an empty one without, exported as PDF, shows exactly that, legend
keys included.

- The chart reader reads a:pattFill (dml::pattern, shared with shapes);
  the chart writer writes it back for an edited chart.
- Bars and their legend keys paint with the series' or point's own fill:
  a colour, a preset pattern (pattern::fill) or nothing, then the series'
  solid outline at its width.
- Lines, areas and markers, which take one colour, use a pattern's
  foreground colour instead of the palette's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dominik Strebel
2026-10-22 09:02:09 +02:00
co-authored by Claude Opus 5.5
parent 128cb0b883
commit 08b7b5eb15
6 changed files with 173 additions and 35 deletions
+88 -28
View File
@@ -29,31 +29,70 @@ pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba {
}
}
/// A series' one colour (lines, areas, markers): its solid fill, a pattern's foreground, else the
/// palette's.
pub(crate) fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba {
match s.and_then(|s| s.fill.as_ref()) {
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
Some(Fill::Pattern(p)) => ctx.color(&p.fg, None),
_ => series_color(ctx, c, i),
}
}
/// The fill of point `k` of a series: its own (`c:dPt`) where the file gives one, else the series'.
fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series, k: usize) -> Rgba {
match s.point_fills.iter().find(|(p, _)| *p == k).map(|(_, f)| f) {
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
_ => series_fill(ctx, c, i, Some(s)),
/// How a bar, or its legend key, is painted.
#[derive(Clone, Debug, PartialEq)]
enum Paint {
Color(Rgba),
/// A preset pattern: name, foreground, background.
Pattern(String, Rgba, Rgba),
/// `a:noFill`: only the outline, if any, shows.
Nothing,
}
/// The paint of fill `f` as a bar or legend key draws it; the palette's `fallback` when the file
/// gives none (or one a bar can't show).
fn paint_of(ctx: &Ctx, f: Option<&Fill>, fallback: Rgba) -> Paint {
match f {
Some(Fill::Solid { color: cc }) => Paint::Color(ctx.color(cc, None)),
Some(Fill::Pattern(p)) => Paint::Pattern(p.preset.clone(), ctx.color(&p.fg, None), ctx.color(&p.bg, None)),
Some(Fill::None) => Paint::Nothing,
_ => Paint::Color(fallback),
}
}
/// Fill of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
/// (not waterfalls, which are never read from a `<c:barChart>`).
fn bar_fill(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Rgba {
if s.point_fills.iter().any(|(k, _)| *k == pi) {
return point_fill(ctx, c, si, s, pi);
/// Fill `r` (in the space `m` maps to the output) with `p`.
fn fill_with(ctx: &mut RenderContext, p: &Paint, r: Rect, m: Affine) {
match p {
Paint::Color(c) => {
ctx.set_transform(m);
ctx.set_paint(color(*c, 1.0));
ctx.fill_rect(&r);
}
Paint::Pattern(name, fg, bg) => crate::pattern::fill(ctx, name, *fg, *bg, 1.0, &r.to_path(0.1), m),
Paint::Nothing => {}
}
}
/// A bar series' outline (`c:spPr/a:ln`) where the file gives it a solid colour: colour and width.
fn bar_outline(ctx: &Ctx, s: &deckcraft_model::chart::Series) -> Option<(Rgba, f64)> {
let l = s.line.as_ref()?;
match l.fill.as_ref()? {
Fill::Solid { color: cc } => Some((ctx.color(cc, None), l.width.unwrap_or(0.75).clamp(0.0, 20.0))),
_ => None,
}
}
/// Paint of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
/// (not waterfalls, which are never read from a `<c:barChart>`). Pattern fills draw as patterns and
/// `a:noFill` leaves the bar empty, as in PowerPoint.
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Paint {
if let Some((_, f)) = s.point_fills.iter().find(|(k, _)| *k == pi) {
return paint_of(ctx, Some(f), series_fill(ctx, c, si, Some(s)));
}
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
return series_color(ctx, c, pi);
return Paint::Color(series_color(ctx, c, pi));
}
series_fill(ctx, c, si, Some(s))
paint_of(ctx, s.fill.as_ref(), series_color(ctx, c, si))
}
fn polyline(pts: &[Point]) -> BezPath {
@@ -235,16 +274,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut);
// Legend placed by `c:legendPos`.
let waterfall = c.kind == ChartType::Waterfall;
let legend_items: Vec<(String, Rgba)> = if waterfall {
["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), series_color(rctx, c, i))).collect()
// Each legend entry: its name, its key's paint and the key's outline.
let legend_items: Vec<(String, Paint, Option<(Rgba, f64)>)> = if waterfall {
["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect()
} else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) {
// A hierarchy's legend names its top-level groups.
c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), series_color(rctx, c, i))).collect()
c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect()
} else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) {
// A map draws its own colour scale.
vec![]
} else if pie {
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect()
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect()
} else {
c.series
.iter()
@@ -252,13 +292,24 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
.map(|(i, s)| {
let k = c.series_kind(s);
let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
(s.name.clone(), if line { series_line(rctx, c, i, s) } else { series_fill(rctx, c, i, Some(s)) })
if line {
(s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None)
} else if k.is_bar_like() {
let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() {
Paint::Color(series_color(rctx, c, i))
} else {
paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i))
};
(s.name.clone(), paint, bar_outline(rctx, s))
} else {
(s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None)
}
})
.collect()
};
if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) {
let vertical = matches!(pos.as_str(), "l" | "r" | "tr");
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
let size = if vertical {
(item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5)
} else {
@@ -267,10 +318,15 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay);
area = plot_area;
let (mut x, mut y) = (origin.x, origin.y);
for (name, col) in &legend_items {
ctx.set_transform(m);
ctx.set_paint(color(*col, 1.0));
ctx.fill_rect(&Rect::new(x, y + base * 0.3, x + base * 0.7, y + base));
for (name, paint, outline) in &legend_items {
let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base);
fill_with(ctx, paint, key, m);
if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) {
ctx.set_transform(m);
ctx.set_paint(color(oc, 1.0));
ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2)));
ctx.stroke_path(&key.to_path(0.1));
}
let tw = name.chars().count() as f64 * base * 0.55 + base;
label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
if vertical {
@@ -625,7 +681,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
.abs();
// A waterfall colours increases, decreases and totals, and joins each bar's end
// to the next bar's start.
let fill = if waterfall {
let paint = if waterfall {
let slot = if c.subtotals.contains(&i) {
2
} else if b < a {
@@ -633,13 +689,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
} else {
0
};
series_color(rctx, c, slot)
Paint::Color(series_color(rctx, c, slot))
} else {
bar_fill(rctx, c, *si, i, s)
bar_paint(rctx, c, *si, i, s)
};
ctx.set_transform(m);
ctx.set_paint(color(fill, 1.0));
ctx.fill_rect(&r.abs());
fill_with(ctx, &paint, r.abs(), m);
if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) {
ctx.set_transform(m);
ctx.set_paint(color(oc, 1.0));
ctx.set_stroke(kurbo::Stroke::new(ow));
ctx.stroke_path(&r.abs().to_path(0.1));
}
if waterfall && i + 1 < ncat && !horiz {
let y = to_v(b);
let mut j = BezPath::new();
+37
View File
@@ -340,6 +340,43 @@ fn chart_axis_titles_are_drawn() {
assert!(ink(&titled, bottom) > ink(&bare, bottom) + 30, "category axis title");
}
#[test]
fn column_series_draw_their_pattern_fill_and_no_fill_with_their_outline() {
// Three column series, as PowerPoint draws them: a red-on-yellow pattern, no fill with a blue
// outline, and no fill at all. None takes a palette colour; the legend keys match the bars.
use deckcraft_model::style::{Fill, PatternFill};
let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b));
let mut c = Chart::sample(ChartType::Column);
c.title = None;
c.series.truncate(3);
c.categories = vec!["A".into()];
for s in &mut c.series {
s.values = vec![Some(10.0)];
}
c.series[0].fill = Some(Fill::Pattern(PatternFill { preset: "dkDnDiag".into(), fg: rgb(255, 0, 0), bg: rgb(255, 255, 0) }));
c.series[1].fill = Some(Fill::None);
c.series[1].line = Some(deckcraft_model::style::Line { width: Some(3.0), fill: Some(Fill::solid(rgb(0, 0, 255))), ..Default::default() });
c.series[2].fill = Some(Fill::None);
let sh =
Shape { id: ShapeId(625), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c.clone())), ..Default::default() };
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
let count =
|f: &dyn Fn([u8; 4]) -> bool| (0..img.width).flat_map(|x| (0..img.height).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count();
let red = count(&|p| p[0] > 220 && p[1] < 40 && p[2] < 40);
let yellow = count(&|p| p[0] > 220 && p[1] > 220 && p[2] < 40);
let blue = count(&|p| p[0] < 40 && p[1] < 40 && p[2] > 220);
assert!(red > 1000 && yellow > 1000, "the pattern's two colours: {red} red, {yellow} yellow");
assert!(blue > 300, "the empty series' outline: {blue}");
// No palette colour: the theme's accents would show for any series drawn the old way.
let p = deck_with(vec![]);
let ctx = deckcraft_model::resolve::Ctx::for_slide(&p, &p.slides[0]).unwrap();
for i in 0..3 {
let a = crate::chart::series_color(&ctx, &c, i);
let n = count(&|q| q[0].abs_diff(a.r) < 6 && q[1].abs_diff(a.g) < 6 && q[2].abs_diff(a.b) < 6);
assert!(n < 50, "palette colour {i} drawn on {n} pixels");
}
}
#[test]
fn column_points_take_their_own_fill_and_smooth_lines_curve() {
// One column series where only the middle point is given a red c:dPt fill.