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
+2
View File
@@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 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-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
@@ -245,6 +246,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
| 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.