diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index acc597a0..b0778ebd 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -34,6 +34,9 @@ pub enum ChartType { Funnel, Treemap, Sunburst, + /// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it + /// is not offered in the chart menus (PowerPoint needs an online map service to make one). + RegionMap, Combo, } @@ -51,7 +54,7 @@ impl ChartType { /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback). pub fn is_chartex(self) -> bool { - Self::OFFICE_2016.contains(&self) + Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap } pub const MAIN: [ChartType; 12] = [ @@ -96,6 +99,7 @@ impl ChartType { ChartType::Funnel => "Funnel", ChartType::Treemap => "Treemap", ChartType::Sunburst => "Sunburst", + ChartType::RegionMap => "Map", ChartType::Combo => "Combo", } } diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs index 339d26c3..d8f3e9ce 100644 --- a/crates/pptx/src/read/chartex.rs +++ b/crates/pptx/src/read/chartex.rs @@ -66,12 +66,12 @@ fn kind_of(layout: &str) -> Option { "sunburst" => ChartType::Sunburst, "clusteredColumn" => ChartType::Histogram, "boxWhisker" => ChartType::BoxWhisker, + "regionMap" => ChartType::RegionMap, _ => return None, }) } -/// Parse a `chartEx` part. `None` for anything else, and for layouts DeckCraft has no type -/// for (region maps). +/// Parse a `chartEx` part. `None` for anything else, and for layouts DeckCraft has no type for. pub fn read_chartex_xml(bytes: &[u8]) -> Option { let doc = crate::xml::parse(bytes).ok()?; let mut root = doc.root; @@ -110,7 +110,7 @@ pub(crate) fn chartex_from(root: &El) -> Option { }); let d = datas.get(s.child("dataId").and_then(|i| i.attr("val")).unwrap_or("0"))?; let (values, format) = - ["val", "size"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default(); + ["val", "size", "colorVal"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default(); if c.categories.is_empty() { c.categories = d.categories.clone(); c.category_levels = d.levels.clone(); diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index e2f93e3b..f89a55fe 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -588,6 +588,18 @@ fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) { w.close("cx:lvl"); } +/// The `cx:layoutPr` of the region map series in a kept `chartEx` part. +fn kept_region_layout(raw: &str) -> Option { + let doc = crate::xml::parse(raw.as_bytes()).ok()?; + let mut root = doc.root; + crate::read::resolve_mc(&mut root, 0); + let region = root.child("chart")?.child("plotArea")?.child("plotAreaRegion")?; + let s = region.children_named("series").find(|s| s.attr("layoutId") == Some("regionMap"))?; + let lp = s.child("layoutPr")?; + // Written under our `cx` prefix; a part that bound chartex to another prefix is not copied. + lp.name.starts_with("cx:").then(|| lp.to_xml()) +} + /// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data` /// per series (categories and values), then the plot area with each series' layout, the axes /// the layout uses, the title and the legend. @@ -605,7 +617,11 @@ pub fn build_chartex(c: &Chart) -> Vec { } w.close("cx:strDim"); } - let ty = if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { "size" } else { "val" }; + let ty = match c.kind { + ChartType::Treemap | ChartType::Sunburst => "size", + ChartType::RegionMap => "colorVal", + _ => "val", + }; w.open("cx:numDim", A::new().a("type", ty)); let vals: Vec = s.values.iter().map(|v| v.map(fmt_num).unwrap_or_default()).collect(); cx_level(&mut w, &vals, Some(s.number_format.as_deref().unwrap_or("General"))); @@ -627,6 +643,7 @@ pub fn build_chartex(c: &Chart) -> Vec { ChartType::Treemap => "treemap", ChartType::Sunburst => "sunburst", ChartType::BoxWhisker => "boxWhisker", + ChartType::RegionMap => "regionMap", _ => "clusteredColumn", }; let one = !matches!(c.kind, ChartType::BoxWhisker); @@ -666,6 +683,13 @@ pub fn build_chartex(c: &Chart) -> Vec { w.empty("cx:statistics", A::new().a("quartileMethod", if c.inclusive_quartiles { "inclusive" } else { "exclusive" })); w.close("cx:layoutPr"); } + ChartType::RegionMap => { + // The map's geography (projection, region level and the cached boundaries + // PowerPoint downloaded) is not modelled: carry it over from the part we read. + if let Some(lp) = c.raw.as_deref().and_then(kept_region_layout) { + w.raw(&lp); + } + } _ => {} } w.close("cx:series"); @@ -676,7 +700,7 @@ pub fn build_chartex(c: &Chart) -> Vec { w.close("cx:series"); } w.close("cx:plotAreaRegion"); - if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { + if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) { w.open("cx:axis", A::new().a("id", "0")); w.empty("cx:catScaling", A::new().a("gapWidth", if matches!(c.kind, ChartType::Histogram | ChartType::Pareto) { "0" } else { "0.5" })); w.empty0("cx:tickLabels"); @@ -706,3 +730,34 @@ pub fn build_chartex(c: &Chart) -> Vec { w.close("cx:chartSpace"); w.finish() } + +#[cfg(test)] +mod region_map_tests { + use super::*; + + /// A region map part as PowerPoint writes it (our own numbers; the cached geography is a + /// stand-in blob). + const MAP: &str = r#"Sheet1!$A$2:$A$4MinskBrestGomelSheet1!$B$2:$B$41258Sheet1!$B$1SalesZGVja2NyYWZ0LXRlc3Q="#; + + #[test] + fn region_maps_keep_their_geography_through_save_and_edit() { + let c = crate::read::read_chartex_xml(MAP.as_bytes()).expect("a region map is read"); + assert_eq!(c.kind, ChartType::RegionMap); + assert_eq!(c.categories, ["Minsk", "Brest", "Gomel"]); + assert_eq!(c.series.first().map(|s| s.values.clone()), Some(vec![Some(12.0), Some(5.0), Some(8.0)])); + // Unchanged: written back as read. + let kept = String::from_utf8(chartex_xml(&c).unwrap_or_default()).unwrap_or_default(); + assert!(kept.contains("ZGVja2NyYWZ0LXRlc3Q=") && kept.contains("cultureRegion=\"BY\""), "{kept}"); + // Edited: rebuilt from the model, with the geography carried over. + let mut e = c.clone(); + if let Some(s) = e.series.first_mut() { + s.values[1] = Some(20.0); + } + let built = String::from_utf8(chartex_xml(&e).unwrap_or_default()).unwrap_or_default(); + assert!(built.contains(" = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); if vals.iter().all(|v| *v <= 0.0) { return; @@ -64,6 +68,93 @@ fn funnel( } } +/// Mix `a` towards `b` by `t` (0..1). +fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba { + let t = t.clamp(0.0, 1.0); + let f = |x: u8, y: u8| (x as f64 + (y as f64 - x as f64) * t).round().clamp(0.0, 255.0) as u8; + Rgba::rgb(f(a.r, b.r), f(a.g, b.g), f(a.b, b.b)) +} + +/// A region map without its geography: the boundaries PowerPoint caches come from an online +/// map service in an undocumented format and DeckCraft ships no map data, so each region is a +/// tile in a grid, coloured on the map's value scale, with the scale below and a note saying +/// the map itself is not drawn. +fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let Some(s) = c.series.first() else { return }; + let regions: Vec<(String, Option)> = + c.categories.iter().enumerate().map(|(i, n)| (n.clone(), s.values.get(i).copied().flatten())).filter(|(n, _)| !n.is_empty()).collect(); + let note_h = base * 1.4; + let scale_h = base * 2.6; + let grid = Rect::new(area.x0, area.y0, area.x1, (area.y1 - note_h - scale_h).max(area.y0)); + let hi = series_color(rctx, c, 0); + let lo = mix(hi, Rgba::WHITE, 0.85); + let (min, max) = regions.iter().filter_map(|(_, v)| *v).fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(v), b.max(v))); + let span = if max > min { max - min } else { 1.0 }; + let n = regions.len(); + if n > 0 && grid.area() > 0.0 { + // Columns so that tiles come out about twice as wide as tall. + let aspect = grid.width() / grid.height().max(1.0); + let cols = ((n as f64 * aspect / 2.0).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols).max(1); + let (tw, th) = (grid.width() / cols as f64, grid.height() / rows as f64); + for (i, (name, v)) in regions.iter().enumerate() { + let (col, row) = (i % cols, i / cols); + let t = Rect::new(grid.x0 + tw * col as f64, grid.y0 + th * row as f64, grid.x0 + tw * (col + 1) as f64, grid.y0 + th * (row + 1) as f64) + .inset(-1.5); + let fill = v.map_or(Rgba::rgb(0xD9, 0xD9, 0xD9), |v| mix(lo, hi, (v - min) / span)); + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_rect(&t); + let dark = v.is_some_and(|v| (v - min) / span > 0.55); + let mut text = name.clone(); + if c.data_labels + && let Some(v) = v + { + text = format!( + "{text} +{}", + c.data_label(s, *v) + ); + } + let inner = t.inset(-base * 0.25); + if inner.width() > base && inner.height() > base * 0.8 { + label(ctx, rctx, m, &text, inner, base * 0.9, Align::Center, Anchor::Middle, if dark { Rgba::WHITE } else { text_col }, false); + } + } + } + // The colour scale: a gradient bar from the lowest to the highest value. + if min <= max { + let bar_w = (area.width() * 0.5).min(base * 24.0); + let y = grid.y1 + base * 0.6; + let bar = Rect::new(area.center().x - bar_w / 2.0, y, area.center().x + bar_w / 2.0, y + base * 0.8); + let steps = 48; + for k in 0..steps { + let x0 = bar.x0 + bar.width() * k as f64 / steps as f64; + let x1 = bar.x0 + bar.width() * (k + 1) as f64 / steps as f64 + 0.5; + ctx.set_transform(m); + ctx.set_paint(color(mix(lo, hi, k as f64 / (steps - 1) as f64), 1.0)); + ctx.fill_rect(&Rect::new(x0, bar.y0, x1.min(bar.x1), bar.y1)); + } + let ly = Rect::new(bar.x0 - base * 8.0, bar.y0 - base * 0.2, bar.x0 - base * 0.4, bar.y1 + base * 0.2); + label(ctx, rctx, m, &c.data_label(s, min), ly, base, Align::Right, Anchor::Middle, text_col, false); + let ry = Rect::new(bar.x1 + base * 0.4, bar.y0 - base * 0.2, bar.x1 + base * 8.0, bar.y1 + base * 0.2); + label(ctx, rctx, m, &c.data_label(s, max), ry, base, Align::Left, Anchor::Middle, text_col, false); + } + let note = Rect::new(area.x0, area.y1 - note_h, area.x1, area.y1); + label( + ctx, + rctx, + m, + "Map preview unavailable: regions shown as tiles", + note, + base * 0.85, + Align::Center, + Anchor::Middle, + mix(text_col, Rgba::WHITE, 0.4), + false, + ); +} + /// Squarified treemap (Bruls, Huizing, van Wijk): rows of tiles laid along the short side, /// each row kept while it improves the worst aspect ratio. pub fn squarify(vals: &[f64], r: Rect) -> Vec { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e4770579..dd44e127 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2048,3 +2048,34 @@ fn multilevel_treemap_and_sunburst_draw_their_groups() { assert!(seen.len() >= 6, "{kind:?}: {} colours", seen.len()); } } + +/// A region map without drawable geography falls back to coloured tiles on the value scale. +#[test] +fn region_map_draws_tiles_on_its_colour_scale() { + let names = ["Brest", "Vitebsk", "Gomel", "Grodno", "Minsk", "Mogilev", "Minsk City"]; + let vals = [3.1, 2.2, 2.9, 4.8, 6.0, 1.4, 9.5]; + let c = Chart { + kind: ChartType::RegionMap, + title: Some("Sales by region".into()), + categories: names.iter().map(|s| s.to_string()).collect(), + series: vec![deckcraft_model::chart::Series { name: "Sales".into(), values: vals.iter().map(|v| Some(*v)).collect(), ..Default::default() }], + data_labels: true, + ..Default::default() + }; + let sh = Shape { id: ShapeId(624), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/RegionMap-tiles.png"), img.to_png()); + } + let mut seen: Vec<[u8; 3]> = Vec::new(); + for y in (60..520).step_by(6) { + for x in (40..920).step_by(6) { + let p = img.pixel(x, y); + let c = [p[0] / 16, p[1] / 16, p[2] / 16]; + if c != [15, 15, 15] && !seen.contains(&c) { + seen.push(c); + } + } + } + assert!(seen.len() >= 5, "{} colours", seen.len()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e60ca856..519ca6d5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -94,7 +94,7 @@ the round trip even though DeckCraft doesn't model it. | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save. A diagram with no relationship parts keeps its XML; the drawing and data parts are still dropped (#203) | | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | -| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; no region maps | +| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | diff --git a/docs/gaps.md b/docs/gaps.md index 80a4c8f4..fda4bcec 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -213,8 +213,9 @@ Detail in [file-format-parity.md](file-format-parity.md). written from the model, with a plain chart fallback). Waterfalls float and total, funnels, treemaps (squarified, nested by level with group banners) and sunbursts (one ring per level) have their own layouts, histograms and Paretos bin their data (count, width or Scott's rule), - and box & whisker charts use exclusive or inclusive quartiles with 1.5 IQR whiskers. Region maps - remain. + and box & whisker charts use exclusive or inclusive quartiles with 1.5 IQR whiskers. Region maps are kept + (their cached geography too, also through edits) and drawn as value-coloured tiles with a + colour scale, since DeckCraft ships no map boundaries. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).