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
+2
View File
@@ -146,6 +146,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
@@ -276,6 +277,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
| 2026-10-22 | trivial | Progress log: chart series pattern and no fills |
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
+25 -1
View File
@@ -5,7 +5,7 @@ use deckcraft_model::style::Fill;
use crate::xml::El;
/// Fills of chart parts never reference images we keep; read colour fills only.
/// Fills of chart parts never reference images we keep; read colour and pattern fills only.
fn simple_fill(sp: Option<&El>) -> Option<Fill> {
let sp = sp?;
for e in sp.elements() {
@@ -13,6 +13,7 @@ fn simple_fill(sp: Option<&El>) -> Option<Fill> {
"noFill" => return Some(Fill::None),
"solidFill" => return super::dml::color(e).map(|color| Fill::Solid { color }),
"gradFill" => return Some(Fill::Gradient(super::dml::gradient(e))),
"pattFill" => return Some(Fill::Pattern(super::dml::pattern(e))),
_ => {}
}
}
@@ -557,6 +558,29 @@ mod tests {
assert!(!read_chart_xml(&crate::write::chart::chart_xml(&c).0).unwrap().minor_gridlines);
}
#[test]
fn series_pattern_fills_are_read_and_written_back() {
// A pattern-filled series and an empty one with an outline, as PowerPoint writes them.
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:spPr><a:pattFill prst="openDmnd"><a:fgClr><a:schemeClr val="accent4"/></a:fgClr><a:bgClr><a:schemeClr val="bg1"/></a:bgClr></a:pattFill></c:spPr><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numLit></c:val></c:ser><c:ser><c:idx val="1"/><c:spPr><a:noFill/><a:ln w="19050"><a:solidFill><a:schemeClr val="accent4"/></a:solidFill></a:ln></c:spPr><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>3</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
let c = read_chart_xml(xml).unwrap();
let accent4 = deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent4);
let pattern = deckcraft_model::style::PatternFill {
preset: "openDmnd".into(),
fg: accent4.clone(),
bg: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1),
};
assert_eq!(c.series[0].fill, Some(Fill::Pattern(pattern.clone())));
assert_eq!(c.series[1].fill, Some(Fill::None));
assert_eq!(c.series[1].line.as_ref().and_then(|l| l.fill.clone()), Some(Fill::solid(accent4)));
// Written out from the model (an edited chart), the pattern survives.
let mut edited = c.clone();
edited.raw = None;
edited.title = Some("Edited".into());
let back = read_chart_xml(&crate::write::chart::chart_xml(&edited).0).unwrap();
assert_eq!(back.series[0].fill, Some(Fill::Pattern(pattern)));
assert_eq!(back.series[1].fill, Some(Fill::None));
}
#[test]
fn log_base_is_read() {
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:lineChart><c:axId val="1"/><c:axId val="2"/></c:lineChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/><c:scaling><c:logBase val="10"/><c:orientation val="minMax"/></c:scaling></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
+10 -5
View File
@@ -86,16 +86,21 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option<Fill> {
Some(p) => Fill::Picture(p),
None => Fill::None,
},
"pattFill" => Fill::Pattern(PatternFill {
preset: f.attr("prst").unwrap_or("pct5").to_string(),
fg: f.child("fgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::BLACK)),
bg: f.child("bgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::WHITE)),
}),
"pattFill" => Fill::Pattern(pattern(f)),
"grpFill" => Fill::Group,
_ => return None,
})
}
/// An `a:pattFill`: its preset (5 % dots when unset), foreground (black) and background (white).
pub fn pattern(f: &El) -> PatternFill {
PatternFill {
preset: f.attr("prst").unwrap_or("pct5").to_string(),
fg: f.child("fgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::BLACK)),
bg: f.child("bgClr").and_then(color).unwrap_or(ColorRef::rgb(Rgba::WHITE)),
}
}
pub fn gradient(f: &El) -> Gradient {
let mut stops: Vec<GradientStop> = f
.child("gsLst")
+11 -1
View File
@@ -30,7 +30,17 @@ fn simple_fill(w: &mut W, f: &Fill) {
match f {
Fill::None => w.empty0("a:noFill"),
Fill::Solid { color } => super::dml::solid(w, color),
Fill::Gradient(_) | Fill::Picture(_) | Fill::Pattern(_) | Fill::Group | Fill::Background => {
Fill::Pattern(p) => {
w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset }));
w.open0("a:fgClr");
super::dml::color(w, &p.fg);
w.close("a:fgClr");
w.open0("a:bgClr");
super::dml::color(w, &p.bg);
w.close("a:bgClr");
w.close("a:pattFill");
}
Fill::Gradient(_) | Fill::Picture(_) | Fill::Group | Fill::Background => {
if let Fill::Gradient(g) = f
&& let Some(s) = g.stops.first()
{
+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.