diff --git a/ROADMAP.md b/ROADMAP.md index f5daa2d3..1504ccd9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 73cc416c..d60b764a 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -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 { let sp = sp?; for e in sp.elements() { @@ -13,6 +13,7 @@ fn simple_fill(sp: Option<&El>) -> Option { "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#"53"#; + 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#""#; diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index c88bd1a9..ef75eca8 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -86,16 +86,21 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option { 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 = f .child("gsLst") diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 88917450..45a190e0 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -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() { diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index ac185780..6c2ea1d5 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -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 `` 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 ``). -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 `` 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 ``). 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 = legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); + let item_ws: Vec = 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(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 4ee1c889..8a0222e2 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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.