From 9acb1241b540f41726c21df0cdeee93d2d2de01c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 15 Oct 2026 01:31:37 +0200 Subject: [PATCH] Charts: draw radar charts (standard, marker, filled) instead of a placeholder (#1890) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/docx/src/read/chart.rs | 203 ++++++++++++++++++++++++++++++++-- docs/gaps.md | 2 +- 3 files changed, 194 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 789842411..c9a5d1ee3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -525,3 +525,4 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-09 | minor | Recently landed: RTL, Ukrainian and Brazilian Portuguese | | 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way | | 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) | +| 2026-10-15 | trivial | Charts: radar charts (standard, marker, filled) are drawn instead of a placeholder (#1890) | diff --git a/crates/docx/src/read/chart.rs b/crates/docx/src/read/chart.rs index 82ed31fbd..47dc6f6c5 100644 --- a/crates/docx/src/read/chart.rs +++ b/crates/docx/src/read/chart.rs @@ -2,7 +2,8 @@ //! //! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter //! charts are drawn from the first supported chart type of the plot area, with their values as -//! data labels where `c:dLbls` shows them (`c:showVal`). A chart of only other types (radar, stock…) +//! data labels where `c:dLbls` shows them (`c:showVal`). Radar charts (standard, with markers or +//! filled) draw as spokes, concentric grid polygons and one polygon per series. A chart of only other types (stock, bubble…) //! draws a placeholder: its frame, its title and the type it is. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate. use std::f64::consts::{FRAC_PI_2, TAU}; @@ -23,6 +24,10 @@ const MAX_POINTS: usize = 10_000; const MAX_CELLS: usize = 100_000; /// Longest series name or category label kept. const MAX_LABEL: usize = 200; +/// Most categories (spokes) of a radar chart. +const MAX_SPOKES: usize = 100; +/// Most series of a radar chart. +const MAX_RADAR_SERIES: usize = 50; /// Most axis ticks drawn. const MAX_TICKS: usize = 20; /// Default text size, points. @@ -75,6 +80,7 @@ pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec pie(&mut out, &chart, area), + Kind::Radar => radar(&mut out, &chart, area), _ => cartesian(&mut out, &chart, area), } out @@ -87,7 +93,7 @@ fn title_block(out: &mut Vec, t: &str, area: Rect) -> Rect { Rect { y: area.y + lh + PAD, h: (area.h - lh - PAD).max(1.0), ..area } } -/// A chart whose plot area holds only chart types WordCraft doesn't draw (radar, stock…): its +/// A chart whose plot area holds only chart types WordCraft doesn't draw (stock, bubble…): its /// frame with a light border, its title and a line saying which type it is, so it isn't blank /// space. Nothing when the plot area has no chart type at all, or a supported one with nothing /// to draw. @@ -114,7 +120,6 @@ fn placeholder(out: &mut Vec, space: &El, theme: &[Rgb], w: f32, h: /// What a placeholder calls a chart type element WordCraft doesn't draw. fn unsupported_name(name: &str) -> &'static str { match name { - "c:radarChart" => "Radar chart", "c:stockChart" => "Stock chart", "c:bubbleChart" => "Bubble chart", "c:surfaceChart" | "c:surface3DChart" => "Surface chart", @@ -144,6 +149,7 @@ enum Kind { Pie, Doughnut, Scatter, + Radar, } impl Kind { @@ -155,6 +161,7 @@ impl Kind { "c:pieChart" => Kind::Pie, "c:doughnutChart" => Kind::Doughnut, "c:scatterChart" => Kind::Scatter, + "c:radarChart" => Kind::Radar, _ => return None, }) } @@ -221,6 +228,10 @@ struct Chart { /// Doughnut hole, fraction of the outer radius. hole: f32, first_angle: f64, + /// Radar: series polygons are filled (`c:radarStyle` = `filled`). + radar_fill: bool, + /// Radar: points carry markers (`c:radarStyle` = `marker`). + radar_marks: bool, vary: bool, /// Number of categories (scatter: points per series at most). n: usize, @@ -253,7 +264,9 @@ impl Chart { return None; } let cats = ty.children("c:ser").find_map(|s| s.child("c:cat")).map(|c| labels(Some(c), MAX_POINTS, date1904)).unwrap_or_default(); - let n = if matches!(kind, Kind::Pie | Kind::Doughnut) { + let n = if kind == Kind::Radar { + series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()).min(MAX_SPOKES) + } else if matches!(kind, Kind::Pie | Kind::Doughnut) { series.first().map_or(0, |s| s.values.len()).max(cats.len()) } else { series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()) @@ -262,6 +275,9 @@ impl Chart { if let Some(keep) = MAX_CELLS.checked_div(n) { series.truncate(keep.max(1)); } + if kind == Kind::Radar { + series.truncate(MAX_RADAR_SERIES); + } let grouping = match ty.child("c:grouping").and_then(|g| g.attr("val")) { Some("stacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Stacked, Some("percentStacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Percent, @@ -269,6 +285,7 @@ impl Chart { }; let mut cats = cats; cats.resize(n, String::new()); + let radar_style = ty.child("c:radarStyle").and_then(|r| r.attr("val")); let type_labels = show_values(ty.child("c:dLbls")); for s in &mut series { s.values.resize(n, None); @@ -296,6 +313,8 @@ impl Chart { overlap: num("c:overlap").unwrap_or(0).clamp(-100, 100) as f32 / 100.0, hole: num("c:holeSize").unwrap_or(50).clamp(10, 90) as f32 / 100.0, first_angle: num("c:firstSliceAng").unwrap_or(0).clamp(0, 360) as f64, + radar_fill: radar_style == Some("filled"), + radar_marks: radar_style == Some("marker"), vary: matches!(kind, Kind::Pie | Kind::Doughnut) && ty.child("c:varyColors").and_then(|v| v.attr("val")).is_none_or(truthy), n, cats, @@ -1324,7 +1343,7 @@ fn cartesian(out: &mut Vec, c: &Chart, area: Rect) { Kind::Line => lines(out, c, &g, &mut labels), Kind::Area => areas(out, c, &g, &mut labels), Kind::Scatter => draw_scatter(out, c, &g, &mut labels), - Kind::Pie | Kind::Doughnut => {} + Kind::Pie | Kind::Doughnut | Kind::Radar => {} } draw_axes(out, c, &g); draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar); @@ -1621,6 +1640,94 @@ fn pie(out: &mut Vec, c: &Chart, area: Rect) { draw_data_labels(out, &labels, c.text_size, false); } +/// A radar chart in `area`: category spokes from the centre, concentric grid polygons at the value +/// ticks, one polygon per series (filled, or outlined with markers) and the category labels at the +/// spoke ends. The lowest tick is the centre. +fn radar(out: &mut Vec, c: &Chart, area: Rect) { + const MARKER: f32 = 5.0; + if c.n == 0 { + return; + } + let fs = c.text_size; + let lh = fs * LINE; + let labelled = c.cats.iter().any(|s| !s.is_empty()); + let side = if labelled { widest(&c.cats, fs).min(area.w * 0.25) + GAP + 2.0 } else { 0.0 }; + let vert = if labelled { lh + GAP } else { 0.0 }; + let r = ((area.w / 2.0 - side).min(area.h / 2.0 - vert) - 2.0) as f64; + if r.is_nan() || r < 8.0 { + return; + } + let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64); + let vs = Axes::of(c).vs; + let span = vs.hi - vs.lo; + let angle = |i: usize| i as f64 / c.n as f64 * TAU; + let at = |v: f64, i: usize| { + let t = if span > 0.0 { ((v - vs.lo) / span).clamp(0.0, 1.0) } else { 0.0 }; + polar(cx, cy, r * t, angle(i)) + }; + let ticks = vs.ticks(); + if c.val.grid { + for t in ticks.iter().skip(1) { + let mut segs: Vec = (0..c.n).map(|i| if i == 0 { pt(at(*t, i)) } else { line_to(at(*t, i)) }).collect(); + segs.push(PathSeg::Close); + stroke(out, segs, GRID, 0.75); + } + } + for i in 0..c.n { + let end = polar(cx, cy, r, angle(i)); + stroke(out, vec![pt((cx, cy)), line_to(end)], GRID, 0.75); + } + let mut labels = Vec::new(); + for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) { + let col = c.series_colour(j); + // A missing value counts as the centre, as Word draws it. + let ring: Vec<(f64, f64)> = (0..c.n).map(|i| at(s.values.get(i).copied().flatten().unwrap_or(vs.lo), i)).collect(); + let mut segs: Vec = ring.iter().enumerate().map(|(i, p)| if i == 0 { pt(*p) } else { line_to(*p) }).collect(); + segs.push(PathSeg::Close); + if c.radar_fill { + fill(out, segs.clone(), col); + } + stroke(out, segs, col, s.line_w); + for (i, p) in ring.iter().enumerate() { + if c.radar_marks && s.values.get(i).copied().flatten().is_some() { + let rect = [p.0 as f32 - MARKER / 2.0, p.1 as f32 - MARKER / 2.0, MARKER, MARKER]; + out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(col), stroke: None, stroke_width: 0.0 }); + } + if let Some(text) = label_of(s, i) { + labels.push(DataLabel { x: p.0 as f32, y: p.1 as f32, text, inside: false }); + } + } + } + draw_data_labels(out, &labels, fs, false); + if !c.val.deleted { + for t in &ticks { + let y = at(*t, 0).1 as f32; + let s = format_value(*t, &c.val_fmt, vs.step); + let w = text_w(&s, fs) + 4.0; + text(out, Rect { x: cx as f32 - GAP / 2.0 - w, y: y - lh / 2.0, w, h: lh }, &s, fs, TextAlign::Right, false); + } + } + if labelled { + for (i, s) in c.cats.iter().enumerate().filter(|(_, s)| !s.is_empty()) { + let a = angle(i); + let (x, y) = polar(cx, cy, r + GAP as f64, a); + let (x, y) = (x as f32, y as f32); + let w = side - GAP; + let txt = clip_text(s, w, fs); + let (sx, sy) = (a.sin(), a.cos()); + let (rect, align) = if sx > 0.2 { + (Rect { x, y: y - lh / 2.0, w, h: lh }, TextAlign::Left) + } else if sx < -0.2 { + (Rect { x: x - w, y: y - lh / 2.0, w, h: lh }, TextAlign::Right) + } else { + let cw = (text_w(&txt, fs) + 4.0).max(1.0); + (Rect { x: x - cw / 2.0, y: if sy > 0.0 { y - lh } else { y }, w: cw, h: lh }, TextAlign::Center) + }; + text(out, rect, &txt, fs, align, false); + } + } +} + /// The data label of point `i` of series `s`, when it shows one: the value as the file has it, /// in the series' number format. fn label_of(s: &Series, i: usize) -> Option { @@ -1849,21 +1956,95 @@ mod tests { fn unsupported_chart_types_draw_a_placeholder() { let ser = r#"1"#; let title = r#"Skills"#; - let x = format!(r#"{title}{ser}"#); - let radar = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0); - assert_eq!(texts(&radar), ["Skills", "Radar chart (not supported yet)"]); - let framed = radar.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0])); - assert!(framed, "a light border marks the chart's frame: {radar:?}"); + let x = format!(r#"{title}{ser}"#); + let bubble = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0); + assert_eq!(texts(&bubble), ["Skills", "Bubble chart (not supported yet)"]); + let framed = bubble.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0])); + assert!(framed, "a light border marks the chart's frame: {bubble:?}"); let stock = chart_items(&space(&format!("{ser}{ser}{ser}")), &theme(), 300.0, 200.0); assert_eq!(texts(&stock), ["Stock chart (not supported yet)"]); let other = chart_items(&space(&format!("{ser}")), &theme(), 300.0, 200.0); assert_eq!(texts(&other), ["Chart (not supported yet)"]); // A supported type in the plot area is drawn, an unsupported one beside it is skipped. - let combo = chart_items(&space(&format!("{ser}{ser}")), &theme(), 300.0, 200.0); + let combo = chart_items(&space(&format!("{ser}{ser}")), &theme(), 300.0, 200.0); assert!(!texts(&combo).iter().any(|t| t.contains("not supported")), "{combo:?}"); } + fn radar_chart(style: &str, sers: &[String]) -> Vec { + let plot = format!(r#"{}"#, sers.concat()); + chart_items(&space(&plot), &theme(), 300.0, 220.0) + } + + #[test] + fn radar_charts_draw_spokes_grid_and_series_polygons() { + let cats = ["Speed", "Power", "Range", "Armor", "Luck"]; + let sers = [ser(0, "A", Some("FF0000"), &cats, &["3", "4", "5", "2", "1"]), ser(1, "B", Some("0000FF"), &cats, &["2", "2", "3", "5", "4"])]; + let std = radar_chart("standard", &sers); + assert!(!texts(&std).iter().any(|t| t.contains("not supported")), "{std:?}"); + for c in cats { + assert!(texts(&std).contains(&c), "category label {c}: {:?}", texts(&std)); + } + let stroked = |items: &[GraphicItem], col: Rgb| items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == col && segs.len() == 6)).count(); + assert_eq!(stroked(&std, Rgb(0xFF, 0, 0)), 1, "one closed pentagon per series: {std:?}"); + assert_eq!(stroked(&std, Rgb(0, 0, 0xFF)), 1); + assert!(filled(&std).is_empty(), "standard radar is not filled"); + assert!(!std.iter().any(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. }))); + // 5 spokes (2 segs each) plus grid pentagons. + let spokes = std.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count(); + assert_eq!(spokes, 5); + assert!(std.iter().any(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 6))); + + let marked = radar_chart("marker", &sers); + let dots = marked.iter().filter(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. })).count(); + assert_eq!(dots, 10, "a marker per point"); + + let solid = radar_chart("filled", &sers); + let f = filled(&solid); + assert_eq!(f.len(), 2); + assert!(f.iter().any(|(c, ..)| *c == Rgb(0xFF, 0, 0)) && f.iter().any(|(c, ..)| *c == Rgb(0, 0, 0xFF))); + } + + #[test] + fn radar_charts_show_a_legend() { + let cats = ["a", "b", "c"]; + let plot = format!("{}", ser(0, "Team", None, &cats, &["1", "2", "3"])); + let items = with_legend(&plot, "r", 300.0, 200.0); + assert!(texts(&items).contains(&"Team"), "{:?}", texts(&items)); + } + + #[test] + fn hostile_radar_charts_never_panic() { + // No categories and no values. + let empty = r#""#; + let _ = chart_items(&space(&format!("{empty}")), &theme(), 300.0, 200.0); + let none = chart_items(&space(""), &theme(), 300.0, 200.0); + assert!(none.is_empty(), "{none:?}"); + // One category: a degenerate polygon, nothing to crash on; two as well. + for n in 1..=2 { + let cats: Vec = (0..n).map(|i| format!("c{i}")).collect(); + let refs: Vec<&str> = cats.iter().map(String::as_str).collect(); + let vals = vec!["5"; n]; + let items = radar_chart("filled", &[ser(0, "S", None, &refs, &vals)]); + assert!(texts(&items).contains(&"c0"), "{items:?}"); + } + // NaN, infinities, junk and a tiny frame. + let junk = ser(0, "S", None, &["a", "b", "c"], &["NaN", "inf", "1e999"]); + let _ = radar_chart("filled", &[junk]); + let small = chart_items(&space(&format!("{}", ser(0, "S", None, &["a", "b", "c"], &["1", "2", "3"]))), &theme(), 20.0, 20.0); + let _ = small; + // Caps: thousands of categories and series stay bounded. + let cats: Vec = (0..5000).map(|i| format!("c{i}")).collect(); + let refs: Vec<&str> = cats.iter().map(String::as_str).collect(); + let vals = vec!["1"; 5000]; + let many: Vec = (0..300).map(|i| ser(i, "S", None, &refs, &vals)).collect(); + let items = radar_chart("marker", &many); + let polygons = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), .. } if *c != GRID)).count(); + assert!(polygons <= MAX_RADAR_SERIES, "{polygons}"); + let spokes = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count(); + assert_eq!(spokes, MAX_SPOKES); + } + /// Chart items for a chart with a legend at `pos` (`r`, `b`…). fn with_legend(plot: &str, pos: &str, w: f32, h: f32) -> Vec { let x = format!( diff --git a/docs/gaps.md b/docs/gaps.md index c698251db..ffb794be9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -33,7 +33,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 5 of the 12 key languages missing (hi, ar, id, ko, vi) and pt-PT (French landed, #729; 19 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); missing: Ink to Math, two-stroke arrows and arrows with a closed triangle head, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | -| 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | +| 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), radar charts are drawn (#1890); no combo/3D/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189) | | #790 stores sources in `customXml` and reads them back; `crates/zotero` | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |