Charts: keep and draw region map charts

Read the regionMap chartEx layout as a RegionMap chart (regions and
their colour values), write the part back as read while unchanged, and
when edited rebuild it with the original geography (projection, culture
and cached boundaries) carried over. With no map data in the repo, the
renderer draws each region as a value-coloured tile with a colour scale
and a note that the map itself is not drawn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 01:23:54 +03:00
co-authored by Cursor
parent b3420a0b04
commit 210df9a3d1
8 changed files with 194 additions and 11 deletions
+5 -1
View File
@@ -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",
}
}
+3 -3
View File
@@ -66,12 +66,12 @@ fn kind_of(layout: &str) -> Option<ChartType> {
"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<Chart> {
let doc = crate::xml::parse(bytes).ok()?;
let mut root = doc.root;
@@ -110,7 +110,7 @@ pub(crate) fn chartex_from(root: &El) -> Option<Chart> {
});
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();
+57 -2
View File
@@ -534,6 +534,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<String> {
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.
@@ -551,7 +563,11 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
}
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<String> = 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")));
@@ -573,6 +589,7 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
ChartType::Treemap => "treemap",
ChartType::Sunburst => "sunburst",
ChartType::BoxWhisker => "boxWhisker",
ChartType::RegionMap => "regionMap",
_ => "clusteredColumn",
};
let one = !matches!(c.kind, ChartType::BoxWhisker);
@@ -612,6 +629,13 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
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");
@@ -622,7 +646,7 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
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");
@@ -652,3 +676,34 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
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#"<cx:chartSpace xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex"><cx:chartData><cx:data id="0"><cx:strDim type="cat"><cx:f>Sheet1!$A$2:$A$4</cx:f><cx:lvl ptCount="3"><cx:pt idx="0">Minsk</cx:pt><cx:pt idx="1">Brest</cx:pt><cx:pt idx="2">Gomel</cx:pt></cx:lvl></cx:strDim><cx:numDim type="colorVal"><cx:f>Sheet1!$B$2:$B$4</cx:f><cx:lvl ptCount="3" formatCode="General"><cx:pt idx="0">12</cx:pt><cx:pt idx="1">5</cx:pt><cx:pt idx="2">8</cx:pt></cx:lvl></cx:numDim></cx:data></cx:chartData><cx:chart><cx:title pos="t" align="ctr" overlay="0"/><cx:plotArea><cx:plotAreaRegion><cx:series layoutId="regionMap" uniqueId="{00000000-0000-4000-8000-000000000001}"><cx:tx><cx:txData><cx:f>Sheet1!$B$1</cx:f><cx:v>Sales</cx:v></cx:txData></cx:tx><cx:dataId val="0"/><cx:layoutPr><cx:geography cultureLanguage="en-US" cultureRegion="BY" attribution="Powered by Bing"><cx:geoCache provider="{E9337A44-BEBE-4D9F-B70C-5C5E7DAFC167}"><cx:binary>ZGVja2NyYWZ0LXRlc3Q=</cx:binary></cx:geoCache></cx:geography></cx:layoutPr></cx:series></cx:plotAreaRegion></cx:plotArea><cx:legend pos="r" align="min" overlay="0"/></cx:chart></cx:chartSpace>"#;
#[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("<cx:geoCache") && built.contains("ZGVja2NyYWZ0LXRlc3Q=") && built.contains("colorVal"), "{built}");
assert!(!built.contains("<cx:axis"), "{built}");
let back = crate::read::read_chartex_xml(built.as_bytes()).expect("the rebuilt part reads");
assert_eq!(back.kind, ChartType::RegionMap);
assert_eq!(back.series.first().and_then(|s| s.values.get(1).copied().flatten()), Some(20.0));
}
}
+3 -2
View File
@@ -180,7 +180,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
} 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()
} else if c.kind == ChartType::Funnel {
} 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()
@@ -274,7 +275,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
}
return;
}
if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst) {
if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) {
crate::chartex::draw(ctx, rctx, c, m, area, base, text_col);
return;
}
+91
View File
@@ -15,6 +15,10 @@ use crate::color;
/// Draw `c` inside `area` (the space left by the title and legend).
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) {
let Some(s) = c.series.first() else { return };
if c.kind == ChartType::RegionMap {
region_tiles(ctx, rctx, c, m, area, base, text_col);
return;
}
let vals: Vec<f64> = 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<f64>)> =
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<Rect> {
+31
View File
@@ -1803,3 +1803,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());
}
+1 -1
View File
@@ -73,7 +73,7 @@ the round trip even though DeckCraft doesn't model it.
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
| 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) |
+3 -2
View File
@@ -150,8 +150,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).