Merge PR #1146: Charts: bars draw their series' pattern fill and no fill

Conflicts: ROADMAP.md (union of progress-log rows)
This commit is contained in:
aiongg
2026-10-24 06:14:53 +00:00
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.