diff --git a/Cargo.lock b/Cargo.lock index 9e71a6e5..45c61525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1053,6 +1053,7 @@ dependencies = [ name = "deckcraft-metafile" version = "0.7.0" dependencies = [ + "image", "kurbo", "thiserror 2.0.21", ] diff --git a/ROADMAP.md b/ROADMAP.md index 6b7c2199..6adffddd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,6 +32,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Design ▸ Effects; percentages unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Trim Media dialog follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -178,6 +179,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | PPTX round trip of comments, hyperlinks and actions, custom shows, rotated groups, and placeholders that inherit from the layout. Paragraph default formatting (`defRPr`, including a link) and action sounds (the clip, stop-previous, and a custom show's "Show and return") are kept. Embedded fonts and comment threads stay open | | 2026-10-13 | PPTX round trip of text effects, autofit, columns, vertical and RTL text, tab stops, line spacing, theme effects, gradient and pattern fills, 3-D bevels and connectors. A run keeps its inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML is written back. Embedded fonts and #70, #74, #77 stay open | | 2026-10-14 | PPTX: an owned corpus round-trips SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a hidden slide, slide-number and date fields, ink (as freeform lines), 3-D models (`.glb`, including the fallback picture) and animation repeat, bounce end and triggers. Relationship-bearing SmartArt and OLE (#203), playback options (#74) and rewind (#77) stay open | +| 2026-10-14 | Chart trendlines are drawn: linear, exponential, logarithmic, polynomial, power and moving average, with forecast and the equation and R² label. The value axis grows to include the fit | | 2026-10-14 | Chart trendlines round-trip (type, polynomial order, moving-average period, forecast, intercept, equation and R²). An owned corpus slice also checks combo charts with data labels, merged tables with diagonal borders, and text columns, autofit, vertical text and a WordArt warp | | 2026-10-14 | Insert › Icons: a gallery of Lucide stroke icons (release 1.56.0) inserted as custom shapes, no fill, round stroke scaled from the 24-unit design | | 2026-10-14 | Size and Position dialog: height, width, rotation, scale and lock aspect ratio in one undo step, from Shape Format › Size and the shape menu. Breadth stays 79% | @@ -244,6 +246,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | trivial | Progress log: PPTX corpus for comments, links, custom shows, groups and inherited placeholders | | 2026-10-13 | trivial | Progress log: PPTX corpus for text effects, body layout, theme effects, fills, bevels and connectors | | 2026-10-14 | trivial | Progress log: PPTX corpus for 3-D models, media bookmarks and animation bounce end | +| 2026-10-14 | trivial | Progress log: chart trendlines are drawn | | 2026-10-14 | trivial | Progress log: chart trendlines | | 2026-10-14 | trivial | Progress log: Lucide icon gallery | | 2026-10-14 | trivial | Progress log: Size and Position dialog. Breadth stays 79%, ready stays ~55% | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 20084231..ab9680b0 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -109,7 +109,7 @@ pub fn specs() -> Vec { "Edit Data", ["Chart Design", "Data"], None, - "{categories?: [..], series?: [{name, values}], id?}", + "{categories?: [..], levels?: [[..]] (outer category levels, innermost first), series?: [{name, values}], id?}", has_selection, chart_data ), @@ -118,7 +118,7 @@ pub fn specs() -> Vec { "Chart Elements", ["Chart Design", "Chart Layouts"], None, - "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, id?}", + "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, id?}", has_selection, chart_options ), @@ -1689,7 +1689,12 @@ fn chart_type_cmd(s: &mut Session, p: &Value) -> Result { let k = chart_type(str_param(p, "type").unwrap_or("column")); with_chart(s, p, "chart.type", |c| { c.kind = k; - c.vary_colors = matches!(k, ChartType::Pie | ChartType::Doughnut) || c.series.len() == 1 && c.vary_colors; + c.vary_colors = + matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall) + || c.series.len() == 1 && c.vary_colors; + if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + c.gridlines = true; + } c.raw = None; Ok(()) }) @@ -1709,10 +1714,21 @@ fn chart_data(s: &mut Session, p: &Value) -> Result { }) .collect() }); + let levels: Option>> = p.get("levels").and_then(Value::as_array).map(|a| { + a.iter() + .take(deckcraft_model::chart::MAX_LEVELS) + .map(|l| { + l.as_array().map(|v| v.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()).unwrap_or_default() + }) + .collect() + }); with_chart(s, p, "chart.data", |c| { if let Some(cs) = &cats { c.categories = cs.clone(); } + if let Some(l) = &levels { + c.category_levels = l.clone(); + } if let Some(se) = &series { // Keep series formatting by position. let old = std::mem::take(&mut c.series); @@ -1733,7 +1749,46 @@ fn chart_data(s: &mut Session, p: &Value) -> Result { } fn chart_options(s: &mut Session, p: &Value) -> Result { + // Histogram bins: a count or a width (setting one clears the other; null for automatic). + let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32)); + let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0)); + if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() { + return Err(bad("chart.options", "binSize must be a positive number")); + } + // Waterfall total points. + let subtotals: Option> = + p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); with_chart(s, p, "chart.options", |c| { + if let Some(n) = bin_count { + c.bin_count = n; + if n.is_some() { + c.bin_size = None; + } + } + if let Some(w) = bin_size { + c.bin_size = w; + if w.is_some() { + c.bin_count = None; + } + } + if let Some(t) = &subtotals { + let n = c.series.first().map_or(0, |s| s.values.len()); + let mut t: Vec = t.iter().copied().filter(|i| *i < n).collect(); + t.sort_unstable(); + t.dedup(); + c.subtotals = t; + } + if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) { + c.up_down_bars = b; + c.raw = None; + } + if let Some(q) = p.get("quartiles").and_then(Value::as_str) { + c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive"); + c.raw = None; + } + if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() { + c.raw = None; + } if let Some(t) = p.get("title") { c.title = t.as_str().map(String::from); } diff --git a/crates/metafile/Cargo.toml b/crates/metafile/Cargo.toml index 63f7ee77..1b564539 100644 --- a/crates/metafile/Cargo.toml +++ b/crates/metafile/Cargo.toml @@ -11,3 +11,4 @@ workspace = true [dependencies] kurbo = { workspace = true } thiserror = { workspace = true } +image = { workspace = true } diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index 511fbaed..f286dc61 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -18,6 +18,8 @@ pub(crate) const PS_NULL: u32 = 5; /// Pen style bits; the rest of the style word holds end-cap, join and type flags. pub(crate) const PS_STYLE_MASK: u32 = 0x0F; pub(crate) const BS_SOLID: u32 = 0; +/// Cells on the longer side of a computed (gradient or texture) fill tile. +const SHADE_RES: usize = 128; pub(crate) const BS_NULL: u32 = 1; pub(crate) const BS_HATCHED: u32 = 2; /// BS_PATTERN, BS_DIBPATTERN and BS_DIBPATTERNPT: the brush paints a bitmap tile. @@ -330,6 +332,8 @@ pub(crate) struct Canvas { clip: Option>, /// Device bounds of the picture, so excluding from the default clip has a surface to cut. surface: Option<[f64; 4]>, + /// EMF+ convex device clip polygon: drawing is cut to it geometrically. + plus_clip: Option>, } impl Canvas { @@ -355,6 +359,7 @@ impl Canvas { full: false, clip: None, surface: None, + plus_clip: None, } } @@ -368,7 +373,19 @@ impl Canvas { self.full } - fn push(&mut self, mut raw: Raw) { + fn push(&mut self, raw: Raw) { + match self.plus_clip.take() { + None => self.push_raw(raw), + Some(c) => { + for r in crate::cliprect::clip_raw(raw, &c) { + self.push_raw(r); + } + self.plus_clip = Some(c); + } + } + } + + fn push_raw(&mut self, mut raw: Raw) { if self.clip.as_ref().is_some_and(Vec::is_empty) { return; } @@ -493,6 +510,69 @@ impl Canvas { self.brush = brush; } + /// Sets the EMF+ convex device clip polygon (None: unclipped). + pub fn set_clip(&mut self, clip: Option>) { + self.plus_clip = clip; + } + + /// Logical to device as one affine transform (the mapping is affine). + pub fn device_affine(&self) -> Xform { + let o = self.map.device((0.0, 0.0)); + let x = self.map.device((1.0, 0.0)); + let y = self.map.device((0.0, 1.0)); + [x.0 - o.0, x.1 - o.1, y.0 - o.0, y.1 - o.1, o.0, o.1] + } + + /// Text colour, font and alignment set directly (EMF+ strings name their own font and brush); + /// the background is transparent. + pub fn set_text_style(&mut self, color: [u8; 4], font: LogFont, align: u32) { + self.text.color = color; + self.text.bk_opaque = false; + self.text.align = align; + self.text.font = Some(Arc::new(font)); + } + + /// Pushes an item already in device units (a nested metafile's drawing). + pub fn push_device(&mut self, raw: Raw) { + self.push(raw); + } + + /// Fills logical `ops` with a ready-made tile, laid from the device origin. + pub fn tile_shape(&mut self, ops: &[Op], tile: Arc) { + let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); + if dev.len() >= 2 { + let even_odd = self.even_odd; + self.push(Raw::Pattern { ops: dev, even_odd, tile, region: None }); + } + } + + /// Fills logical `ops` with a colour computed per device point (gradients, textures): a tile + /// of at most [`SHADE_RES`] cells on the longer side, laid out so that its one repeat covers + /// the shape. + pub fn shade_shape(&mut self, ops: &[Op], shade: &dyn Fn(P) -> [u8; 4]) { + let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); + let Some([l, t, r, b]) = crate::cliprect::ops_bbox(&dev) else { return }; + let side = (r - l).max(b - t); + if dev.len() < 2 || !(side.is_finite() && side > 0.0) { + return; + } + let cell = side / SHADE_RES as f64; + let w = (((r - l) / cell).ceil() as usize + 1).clamp(1, SHADE_RES + 1); + let h = (((b - t) / cell).ceil() as usize + 1).clamp(1, SHADE_RES + 1); + let (pw, ph) = (w as f64 * cell, h as f64 * cell); + let mut px = Vec::with_capacity(w * h * 4); + for j in 0..h { + // The device position this tile row stands for inside the shape's box. + let y = t + (j as f64 * cell - t).rem_euclid(ph) + cell / 2.0; + for i in 0..w { + let x = l + (i as f64 * cell - l).rem_euclid(pw) + cell / 2.0; + px.extend_from_slice(&shade((x, y))); + } + } + let even_odd = self.even_odd; + self.push(Raw::Pattern { ops: dev, even_odd, tile: Arc::new(RawTile { w, h, px, cell }), region: None }); + } + /// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores /// could no longer match the file. pub fn save_dc(&mut self) { diff --git a/crates/metafile/src/cliprect.rs b/crates/metafile/src/cliprect.rs new file mode 100644 index 00000000..396f4402 --- /dev/null +++ b/crates/metafile/src/cliprect.rs @@ -0,0 +1,329 @@ +//! Clipping drawn output to a convex device polygon (EMF+ clip rectangles, and clip paths and +//! regions that are convex; others are reduced to their bounding rectangle). Shapes wholly inside +//! pass unchanged; shapes crossing the edge are flattened and cut: fills with Sutherland–Hodgman +//! per subpath, outlines with Cyrus–Beck per segment. Bitmaps are cropped (and masked outside a +//! non-rectangular polygon) and text gets the polygon's bounding rectangle. + +use crate::canvas::Raw; +use crate::dib::Rgba; +use crate::geom::{Op, P}; + +/// Line segments per Bézier when a crossing shape is flattened. +const CURVE_STEPS: usize = 16; + +/// The bounding box `[l, t, r, b]` of the points in `ops` (control points included). +pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> { + let mut b: Option<[f64; 4]> = None; + let mut add = |p: P| { + let v = b.get_or_insert([p.0, p.1, p.0, p.1]); + *v = [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)]; + }; + for op in ops { + match *op { + Op::M(p) | Op::L(p) => add(p), + Op::C(a, c, d) => { + add(a); + add(c); + add(d); + } + Op::Z => {} + } + } + b +} + +pub(crate) fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]; + // An empty intersection stays empty (zero size) so it clips everything away. + if r[2] < r[0] || r[3] < r[1] { [r[0], r[1], r[0], r[1]] } else { r } +} + +/// A rectangle `[l, t, r, b]` as a polygon. +pub(crate) fn rect_poly([l, t, r, b]: [f64; 4]) -> Vec

{ + vec![(l, t), (r, t), (r, b), (l, b)] +} + +/// The bounding box of a point list. +pub(crate) fn pts_bbox(pts: &[P]) -> Option<[f64; 4]> { + let (&f, rest) = pts.split_first()?; + Some(rest.iter().fold([f.0, f.1, f.0, f.1], |v, p| [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)])) +} + +fn cross(a: P, b: P, p: P) -> f64 { + (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0) +} + +/// A convex clip polygon with positive orientation, or None when `pts` is not convex (or +/// degenerate); duplicate and collinear points are dropped. +pub(crate) fn convex(pts: &[P]) -> Option> { + let mut v: Vec

= Vec::with_capacity(pts.len()); + for &p in pts { + if !(p.0.is_finite() && p.1.is_finite()) { + return None; + } + if v.last().is_none_or(|q: &P| (q.0 - p.0).abs() > 1e-9 || (q.1 - p.1).abs() > 1e-9) { + v.push(p); + } + } + while v.len() > 1 && v.first() == v.last() { + v.pop(); + } + let area: f64 = (0..v.len()).map(|i| cross((0.0, 0.0), v[i], v[(i + 1) % v.len()])).sum(); + if v.len() < 3 || area.abs() < 1e-12 { + return None; + } + if area < 0.0 { + v.reverse(); + } + let n = v.len(); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = cross(v[(i + n - 1) % n], v[i], v[(i + 1) % n]); + if c < -1e-9 * (1.0 + area.abs()) { + return None; + } + if c.abs() > 1e-12 { + out.push(v[i]); + } + } + (out.len() >= 3).then_some(out) +} + +fn inside_poly(clip: &[P], p: P) -> bool { + (0..clip.len()).all(|i| cross(clip[i], clip[(i + 1) % clip.len()], p) >= -1e-9) +} + +/// Whether the clip polygon is an axis-aligned rectangle. +fn is_rect(clip: &[P]) -> bool { + pts_bbox(clip).is_some_and(|b| clip.len() == 4 && clip.iter().all(|p| (p.0 == b[0] || p.0 == b[2]) && (p.1 == b[1] || p.1 == b[3]))) +} + +/// Sutherland–Hodgman: `poly` cut by convex `clip`. +pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec

{ + let mut poly = poly.to_vec(); + for i in 0..clip.len() { + let (a, b) = (clip[i], clip[(i + 1) % clip.len()]); + let keep = |p: P| cross(a, b, p) >= 0.0; + let cut = |p: P, q: P| { + let (cp, cq) = (cross(a, b, p), cross(a, b, q)); + let t = cp / (cp - cq); + (p.0 + (q.0 - p.0) * t, p.1 + (q.1 - p.1) * t) + }; + let mut next = Vec::with_capacity(poly.len() + 4); + for j in 0..poly.len() { + let (p, q) = (poly[(j + poly.len() - 1) % poly.len()], poly[j]); + match (keep(p), keep(q)) { + (true, true) => next.push(q), + (true, false) => next.push(cut(p, q)), + (false, true) => { + next.push(cut(p, q)); + next.push(q); + } + (false, false) => {} + } + } + poly = next; + if poly.is_empty() { + break; + } + } + poly +} + +fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool { + b[2] < c[0] || b[0] > c[2] || b[3] < c[1] || b[1] > c[3] || c[2] <= c[0] || c[3] <= c[1] +} + +fn bbox_inside(b: [f64; 4], clip: &[P]) -> bool { + rect_poly(b).into_iter().all(|p| inside_poly(clip, p)) +} + +/// Clips one drawn item to a convex device polygon; a shape that is both filled and stroked comes +/// back as two items. An empty polygon clips everything away. +pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec { + let Some(cb) = pts_bbox(clip).filter(|_| clip.len() >= 3) else { return Vec::new() }; + match raw { + Raw::Path { ops, fill, stroke, even_odd, region } => { + let Some(b) = ops_bbox(&ops) else { return Vec::new() }; + if bbox_inside(b, clip) { + return vec![Raw::Path { ops, fill, stroke, even_odd, region }]; + } + if disjoint(b, cb) { + return Vec::new(); + } + let subs = flatten(&ops); + let mut out = Vec::new(); + if fill.is_some() { + let f = clip_fill(&subs, clip); + if !f.is_empty() { + out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() }); + } + } + if stroke.is_some() { + let s = clip_stroke(&subs, clip); + if !s.is_empty() { + out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region }); + } + } + out + } + Raw::Pattern { ops, even_odd, tile, region } => { + let Some(b) = ops_bbox(&ops) else { return Vec::new() }; + if bbox_inside(b, clip) { + return vec![Raw::Pattern { ops, even_odd, tile, region }]; + } + if disjoint(b, cb) { + return Vec::new(); + } + let f = clip_fill(&flatten(&ops), clip); + if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] } + } + Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(), + Raw::Text { text: mut t, region } => { + // Text clips to a rectangle: the one around the polygon. + let cur = t.clip.map_or(cb, |[a, b, d, e]| intersect(cb, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)])); + t.clip = Some(cur.map(|v| v as f32)); + vec![Raw::Text { text: t, region }] + } + } +} + +/// Subpaths as point lists with their closed flag. +pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec

, bool)> { + let mut subs: Vec<(Vec

, bool)> = Vec::new(); + for op in ops { + match *op { + Op::M(p) => subs.push((vec![p], false)), + Op::L(p) => match subs.last_mut() { + Some(s) => s.0.push(p), + None => subs.push((vec![p], false)), + }, + Op::C(a, b, d) => { + let Some(s) = subs.last_mut() else { continue }; + let Some(&p0) = s.0.last() else { continue }; + for i in 1..=CURVE_STEPS { + let t = i as f64 / CURVE_STEPS as f64; + let u = 1.0 - t; + let k = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + s.0.push((k[0] * p0.0 + k[1] * a.0 + k[2] * b.0 + k[3] * d.0, k[0] * p0.1 + k[1] * a.1 + k[2] * b.1 + k[3] * d.1)); + } + } + Op::Z => { + if let Some(s) = subs.last_mut() { + s.1 = true; + // Drawing after a close starts from the subpath's first point. + let first = s.0.first().copied(); + if let Some(f) = first { + subs.push((vec![f], false)); + } + } + } + } + } + subs.retain(|s| s.0.len() > 1); + subs +} + +fn clip_fill(subs: &[(Vec

, bool)], clip: &[P]) -> Vec { + let mut ops = Vec::new(); + for (pts, _) in subs { + let poly = clip_polygon(pts, clip); + if poly.len() >= 3 { + ops.push(Op::M(poly[0])); + ops.extend(poly[1..].iter().map(|p| Op::L(*p))); + ops.push(Op::Z); + } + } + ops +} + +fn clip_stroke(subs: &[(Vec

, bool)], clip: &[P]) -> Vec { + let mut ops = Vec::new(); + for (pts, closed) in subs { + let mut pts = pts.clone(); + if *closed && let Some(&f) = pts.first() { + pts.push(f); + } + let mut last: Option

= None; + for w in pts.windows(2) { + let Some((a, b)) = segment(w[0], w[1], clip) else { + last = None; + continue; + }; + if last != Some(a) { + ops.push(Op::M(a)); + } + ops.push(Op::L(b)); + last = Some(b); + } + } + ops +} + +/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`. +fn segment(a: P, b: P, clip: &[P]) -> Option<(P, P)> { + let d = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for i in 0..clip.len() { + let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]); + // Inside is cross(e0, e1, p) >= 0, linear in t along the segment. + let f0 = cross(e0, e1, a); + let df = (e1.0 - e0.0) * d.1 - (e1.1 - e0.1) * d.0; + if df == 0.0 { + if f0 < 0.0 { + return None; + } + } else { + let t = -f0 / df; + if df > 0.0 { + t0 = t0.max(t); + } else { + t1 = t1.min(t); + } + } + } + (t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1))) +} + +/// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to the clip +/// polygon's box; pixels outside a non-rectangular polygon turn transparent. +fn crop(rect: [f64; 4], img: Rgba, clip: &[P]) -> Option<([f64; 4], Rgba)> { + let [x, y, w, h] = rect; + let b = [x, y, x + w, y + h]; + if bbox_inside(b, clip) { + return Some((rect, img)); + } + let c = pts_bbox(clip)?; + if disjoint(b, c) || w <= 0.0 || h <= 0.0 || img.w == 0 || img.h == 0 { + return None; + } + let i = intersect(b, c); + let (kx, ky) = (img.w as f64 / w, img.h as f64 / h); + let x0 = (((i[0] - x) * kx).floor().max(0.0) as usize).min(img.w); + let x1 = (((i[2] - x) * kx).ceil().max(0.0) as usize).min(img.w); + let y0 = (((i[1] - y) * ky).floor().max(0.0) as usize).min(img.h); + let y1 = (((i[3] - y) * ky).ceil().max(0.0) as usize).min(img.h); + if x1 <= x0 || y1 <= y0 { + return None; + } + let mask = !is_rect(clip); + let mut px = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4); + for row in y0..y1 { + // Rows are top-down unless the image says otherwise. + let r = if img.bottom_up { img.h - 1 - row } else { row }; + let s = (r * img.w + x0) * 4; + let start = px.len(); + px.extend_from_slice(img.px.get(s..s + (x1 - x0) * 4)?); + if mask { + let cy = y + (row as f64 + 0.5) / ky; + for (k, p) in px[start..].as_chunks_mut::<4>().0.iter_mut().enumerate() { + let cx = x + ((x0 + k) as f64 + 0.5) / kx; + if !inside_poly(clip, (cx, cy)) { + p[3] = 0; + } + } + } + } + let out = [x + x0 as f64 / kx, y + y0 as f64 / ky, (x1 - x0) as f64 / kx, (y1 - y0) as f64 / ky]; + Some((out, Rgba { w: x1 - x0, h: y1 - y0, bottom_up: false, px })) +} diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index bf2daa3b..94c6d605 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -46,6 +46,7 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { cv.set_device_mm(px_mm); } let mut plus = crate::emfplus::Plus::new(); + plus.frame = frame.as_ref().map(|f| f.rect); let mut parsed = 0usize; let mut pos = hsize.max(88); while !cv.full() { diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index 3aef2842..550c2d18 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -2,14 +2,37 @@ //! //! Most EMF+ files are *dual*: the same picture is also drawn with plain EMF records, which //! [`crate::emf`] already plays, so this reader ignores their EMF+ part. An *EMF+ only* file has -//! nothing else to draw, so it used to show as an empty picture; for those this reader plays the -//! vector records: solid brushes and pens, paths, rectangles, polygons, lines, ellipses and -//! Béziers, under the world and page transforms, save/restore and containers. Images, text, -//! gradient brushes, clipping and arcs are not drawn yet. +//! nothing else to draw; for those this reader plays the records: +//! - brushes (solid, hatch, texture, linear and path gradients), pens, paths, images, fonts, +//! string formats and regions, including objects split across several records; +//! - rectangles, polygons, lines, ellipses, paths and Béziers; +//! - bitmaps (raw pixel formats, and PNG/JPEG/GIF/TIFF/BMP payloads) and strings (DrawString, +//! DrawDriverString with character codes); +//! - world and page transforms, save/restore and containers; +//! - arcs and pies; +//! - clipping to rectangles and convex clip paths and regions (others: their bounding rectangle); +//! - nested metafile images, and glyph-index driver strings in the Windows core fonts; +//! - cardinal splines (DrawCurve, DrawClosedCurve, FillClosedCurve), FillRegion, and Clear +//! (over the picture frame). +//! +//! Every drawing record of MS-EMFPLUS §2.3.4 is played. Not drawn: image attributes, glyph +//! indices of other fonts, and region combinations other than intersect and union (XOR, exclude +//! and complement keep the left area). Deliberately ignored: rendering hints (antialias, text +//! hint, interpolation, pixel offset, compositing; the renderer has its own), SetRenderingOrigin +//! (hatch phase), SerializableObject image effects, the terminal-server records SetTSGraphics and +//! SetTSClip, and StrokeFillPath (0x4037), which the specification lists but gives no layout for +//! and GDI+ doesn't write. + +use std::sync::Arc; use crate::bytes::Bytes; -use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, PS_NULL, Pen, Xform, mul}; +use crate::canvas::Raw; +use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, LogFont, PS_NULL, Pen, RawTile, Xform, hatch_tile, mul}; +use crate::cliprect::{clip_polygon, convex, flatten, ops_bbox, pts_bbox, rect_poly}; +use crate::dib::Rgba; use crate::geom::{Op, P}; +use crate::text::{ETO_CLIPPED, Font}; +use crate::{Item, Picture, Seg}; /// "EMF+" as the comment identifier of an EMR_COMMENT holding EMF+ records. pub(crate) const EMF_PLUS: u32 = 0x2B46_4D45; @@ -19,12 +42,175 @@ const MAX_OBJECTS: usize = 64; const MAX_STACK: usize = 64; /// Points per path or point list read, as the plain EMF reader caps paths. const MAX_POINTS: usize = 100_000; +/// Gradient stops read from a brush. +const MAX_STOPS: usize = 256; +/// Characters per string. +const MAX_CHARS: usize = 32_768; +/// Bytes gathered for one object split across records. +const MAX_OBJECT_BYTES: usize = 64 * 1024 * 1024; +/// Nesting depth of region nodes. +const MAX_REGION_DEPTH: usize = 32; -#[derive(Clone, Debug)] +const TA_RIGHT: u32 = 2; +const TA_CENTER: u32 = 6; +const TA_BOTTOM: u32 = 8; +const TA_BASELINE: u32 = 24; + +/// What a brush paints. Coordinates are in brush space, which `xf` maps to world space. +#[derive(Clone)] +enum Fill { + Solid([u8; 4]), + Hatch { style: u32, fg: [u8; 4], bg: [u8; 4] }, + Linear { rect: [f64; 4], stops: Vec<(f64, [u8; 4])>, wrap: u32, xf: Xform }, + Radial { center: P, radius: P, stops: Vec<(f64, [u8; 4])>, xf: Xform }, + Texture { img: Arc, wrap: u32, xf: Xform }, +} + +impl Fill { + /// One colour standing for the brush (pens and text draw solid). + fn color(&self) -> [u8; 4] { + match self { + Fill::Solid(c) | Fill::Hatch { fg: c, .. } => *c, + Fill::Linear { stops, .. } | Fill::Radial { stops, .. } => ramp(stops, 0.5), + Fill::Texture { img, .. } => img.px.get(..4).and_then(|c| c.try_into().ok()).unwrap_or([0, 0, 0, 255]), + } + } + + /// The colour at a brush-space point. + fn sample(&self, p: P) -> [u8; 4] { + match self { + Fill::Solid(c) | Fill::Hatch { fg: c, .. } => *c, + Fill::Linear { rect, stops, wrap, .. } => { + let t = if rect[2] == 0.0 { 0.0 } else { (p.0 - rect[0]) / rect[2] }; + ramp(stops, wrap_t(t, *wrap)) + } + Fill::Radial { center, radius, stops, .. } => { + let (dx, dy) = ((p.0 - center.0) / radius.0.max(1e-9), (p.1 - center.1) / radius.1.max(1e-9)); + ramp(stops, dx.hypot(dy).min(1.0)) + } + Fill::Texture { img, wrap, .. } => { + let (w, h) = (img.w as f64, img.h as f64); + if w == 0.0 || h == 0.0 { + return [0, 0, 0, 0]; + } + if *wrap == 4 && !(0.0..w).contains(&p.0) || *wrap == 4 && !(0.0..h).contains(&p.1) { + return [0, 0, 0, 0]; + } + let x = (p.0.rem_euclid(w) as usize).min(img.w - 1); + let y = (p.1.rem_euclid(h) as usize).min(img.h - 1); + let i = (y * img.w + x) * 4; + img.px.get(i..i + 4).and_then(|c| c.try_into().ok()).unwrap_or([0, 0, 0, 0]) + } + } + } + + fn xf(&self) -> Xform { + match self { + Fill::Linear { xf, .. } | Fill::Radial { xf, .. } | Fill::Texture { xf, .. } => *xf, + _ => IDENTITY, + } + } +} + +/// Where `t` lands under a wrap mode: tiled, mirrored (flip modes) or clamped. +fn wrap_t(t: f64, wrap: u32) -> f64 { + if !t.is_finite() { + return 0.0; + } + match wrap { + 1 | 3 => { + let m = t.rem_euclid(2.0); + if m > 1.0 { 2.0 - m } else { m } + } + 4 => t.clamp(0.0, 1.0), + _ => t.rem_euclid(1.0), + } +} + +/// The colour at `t` along sorted stops. +fn ramp(stops: &[(f64, [u8; 4])], t: f64) -> [u8; 4] { + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0, 0, 0, 0] }; + if t <= first.0 { + return first.1; + } + for w in stops.windows(2) { + let ((a, ca), (b, cb)) = (w[0], w[1]); + if t <= b { + let k = if b > a { (t - a) / (b - a) } else { 0.0 }; + return lerp(ca, cb, k); + } + } + last.1 +} + +fn lerp(a: [u8; 4], b: [u8; 4], k: f64) -> [u8; 4] { + let mut c = [0u8; 4]; + for i in 0..4 { + c[i] = (f64::from(a[i]) + (f64::from(b[i]) - f64::from(a[i])) * k).round().clamp(0.0, 255.0) as u8; + } + c +} + +#[derive(Clone)] +struct FontObj { + em: f64, + unit: u32, + style: u32, + family: String, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Format { + flags: u32, + align: u32, + line_align: u32, +} + +/// A clip area: a convex polygon (empty clips everything), or None for infinite. +type Area = Option>; + +enum ImageData { + Bitmap(Rgba), + /// A nested WMF or EMF (EMF+) picture. + Meta(Picture), +} + +thread_local! { + /// Nesting depth of metafile images being parsed, so a file of nested metafiles cannot run + /// the stack out. + static NESTING: std::cell::Cell = const { std::cell::Cell::new(0) }; +} +/// Metafile images inside metafile images, at most. +const MAX_NESTING: usize = 3; + +#[derive(Clone)] enum Obj { - Brush([u8; 4]), - Pen { color: [u8; 4], width: f64 }, + Brush(Fill), + Pen { + color: [u8; 4], + width: f64, + }, Path(Vec), + Image(Arc), + Font(FontObj), + Format(Format), + /// A region's bounding rectangle `[l, t, r, b]` in world units; None is infinite. + Region(Area), +} + +/// An object arriving in several records: its id, type, total size and the bytes so far. +struct Partial { + id: usize, + kind: u16, + total: usize, + data: Vec, +} + +struct Saved { + id: u32, + world: Xform, + page: f64, + clip: Area, } pub(crate) struct Plus { @@ -33,13 +219,28 @@ pub(crate) struct Plus { dpi: f64, world: Xform, page: f64, + /// Convex device clip polygon; None is unclipped. + clip: Area, objects: Vec>, - stack: Vec<(u32, Xform, f64)>, + stack: Vec, + partial: Option, + /// The picture frame in device units (`[x, y, w, h]`), what Clear paints. + pub frame: Option<[f64; 4]>, } impl Plus { pub fn new() -> Self { - Plus { active: false, dpi: 96.0, world: IDENTITY, page: 1.0, objects: vec![None; MAX_OBJECTS], stack: Vec::new() } + Plus { + active: false, + dpi: 96.0, + world: IDENTITY, + page: 1.0, + clip: None, + objects: vec![None; MAX_OBJECTS], + stack: Vec::new(), + partial: None, + frame: None, + } } /// Plays the EMF+ records of one EMR_COMMENT payload (after the identifier). @@ -60,6 +261,15 @@ impl Plus { } } + /// World units to device pixels before the canvas's own mapping. + fn xf(&self) -> Xform { + mul(self.world, [self.page, 0.0, 0.0, self.page, 0.0, 0.0]) + } + + fn painter(&self) -> Painter { + Painter { xf: self.xf(), clip: self.clip.clone() } + } + fn record(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { if typ == 0x4001 { // Header: dual files also draw with EMF records, which win. @@ -76,38 +286,129 @@ impl Plus { let id = usize::from(flags & 0xFF); match typ { 0x4008 => self.object(flags, r), - 0x400A => self.fill_rects(cv, flags, r), - 0x400B => self.with_pen(id, |p| p.draw_rects(cv, compressed, r)), - 0x400C => self.fill_polygon(cv, flags, r), - 0x400D => self.with_pen(id, |p| { - let pts = points(r, 4, r.u32(0)? as usize, compressed)?; - p.shape(cv, poly_ops(&pts, flags & 0x2000 != 0), false, true); + 0x400A => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let p = self.painter(); + for rc in rects(r, 8, r.u32(4)? as usize, compressed)? { + p.fill(cv, &rect_ops(rc), &fill); + } Some(()) - }), - 0x400E => self.fill_ellipse(cv, flags, r), - 0x400F => self.with_pen(id, |p| { - let rect = rects(r, 0, 1, compressed)?; - p.shape(cv, ellipse_ops(*rect.first()?), false, true); - Some(()) - }), - 0x4014 => self.fill_path(cv, flags, r), - 0x4015 => { - let path = self.path(id); - r.u32(0).and_then(|pen| { - self.with_pen(pen as usize, |p| { - p.shape(cv, path?, false, true); - Some(()) - }) - }) } - 0x4019 => self.with_pen(id, |p| { + 0x400B => { + let pen = self.pen(id)?; + let p = self.painter(); + for rc in rects(r, 4, r.u32(0)? as usize, compressed)? { + p.stroke(cv, &rect_ops(rc), pen); + } + Some(()) + } + 0x400C => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let pts = points(r, 8, r.u32(4)? as usize, compressed)?; + self.painter().fill(cv, &poly_ops(&pts, true), &fill); + Some(()) + } + 0x400D => { + let pen = self.pen(id)?; + let pts = points(r, 4, r.u32(0)? as usize, compressed)?; + self.painter().stroke(cv, &poly_ops(&pts, flags & 0x2000 != 0), pen); + Some(()) + } + 0x400E => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let rect = *rects(r, 4, 1, compressed)?.first()?; + self.painter().fill(cv, &ellipse_ops(rect), &fill); + Some(()) + } + 0x400F => { + let pen = self.pen(id)?; + let rect = *rects(r, 0, 1, compressed)?.first()?; + self.painter().stroke(cv, &ellipse_ops(rect), pen); + Some(()) + } + 0x4014 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let ops = self.path(id)?; + self.painter().fill(cv, &ops, &fill); + Some(()) + } + 0x4015 => { + let ops = self.path(id)?; + let pen = self.pen(r.u32(0)? as usize)?; + self.painter().stroke(cv, &ops, pen); + Some(()) + } + 0x4019 => { + let pen = self.pen(id)?; let pts = points(r, 4, r.u32(0)? as usize, compressed)?; let (first, rest) = pts.split_first()?; let mut ops = vec![Op::M(*first)]; ops.extend(rest.as_chunks::<3>().0.iter().map(|c| Op::C(c[0], c[1], c[2]))); - p.shape(cv, ops, false, true); + self.painter().stroke(cv, &ops, pen); Some(()) - }), + } + // Cardinal splines: FillClosedCurve, DrawClosedCurve, DrawCurve. + 0x4016 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let tension = finite(r.f32(4)?)?; + let pts = points(r, 12, r.u32(8)? as usize, compressed)?; + self.painter().fill(cv, &curve_ops(&pts, tension, true, 0, usize::MAX), &fill); + Some(()) + } + 0x4017 => { + let pen = self.pen(id)?; + let tension = finite(r.f32(0)?)?; + let pts = points(r, 8, r.u32(4)? as usize, compressed)?; + self.painter().stroke(cv, &curve_ops(&pts, tension, true, 0, usize::MAX), pen); + Some(()) + } + 0x4018 => { + let pen = self.pen(id)?; + let tension = finite(r.f32(0)?)?; + let (offset, segments) = (r.u32(4)? as usize, r.u32(8)? as usize); + let pts = points(r, 16, r.u32(12)? as usize, compressed)?; + self.painter().stroke(cv, &curve_ops(&pts, tension, false, offset, segments), pen); + Some(()) + } + // Clear: the whole picture in one colour, whatever the clip (a transparent clear + // leaves nothing to draw). + 0x4009 => { + let c = argb(r.u32(0)?); + let [x, y, w, h] = self.frame?; + if c[3] == 0 || !(w > 0.0 && h > 0.0) { + return Some(()); + } + let to_device = invert(mul(cv.world(), cv.device_affine()))?; + let p = Painter { xf: to_device, clip: None }; + p.fill(cv, &rect_ops([x, y, x + w, y + h]), &Fill::Solid(c)); + Some(()) + } + // FillRegion: the region's area (as the clip reads it). + 0x4013 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let Some(Some(Obj::Region(Some(area)))) = self.objects.get(id).cloned() else { return None }; + if area.len() < 3 { + return None; + } + self.painter().fill(cv, &poly_ops(&area, true), &fill); + Some(()) + } + 0x401A => { + let img = self.image(id)?; + let dst = *rects(r, 24, 1, compressed)?.first()?; + self.draw_image(cv, &img, r, [dst[0], dst[1], dst[2] - dst[0], dst[3] - dst[1]]) + } + 0x401B => { + let img = self.image(id)?; + let pts = points(r, 28, 3, compressed)?; + let (p0, p1, p2) = (*pts.first()?, *pts.get(1)?, *pts.get(2)?); + // The destination parallelogram's top-left, top-right and bottom-left corners; a + // sheared or rotated one is drawn into its bounding rectangle's axes. + let dst = [p0.0, p0.1, p1.0 - p0.0 + (p2.0 - p0.0), p2.1 - p0.1 + (p1.1 - p0.1)]; + self.draw_image(cv, &img, r, dst) + } + 0x401C => self.draw_string(cv, flags, r), + 0x4036 => self.draw_driver_string(cv, flags, r), 0x4025 | 0x4028 => r.u32(0).map(|i| self.push(i)), 0x4027 => r.u32(24).map(|i| self.push(i)), 0x4026 | 0x4029 => r.u32(0).map(|i| self.pop(i)), @@ -124,20 +425,55 @@ impl Plus { self.apply([a.cos(), a.sin(), -a.sin(), a.cos(), 0.0, 0.0], flags) } 0x4030 => { - let scale = f64::from(r.f32(0)?); - let unit = match flags & 0xFF { - 3 => self.dpi / 72.0, - 4 => self.dpi, - 5 => self.dpi / 300.0, - 6 => self.dpi / 25.4, - _ => 1.0, - }; - let page = scale * unit; + let page = f64::from(r.f32(0)?) * unit_px(u32::from(flags & 0xFF), self.dpi); if page.is_finite() && page > 0.0 && page < 1.0e6 { self.page = page; } Some(()) } + 0x4031 => { + self.clip = None; + Some(()) + } + 0x4032 => { + let [x, y, w, h] = floats::<4>(r, 0)?; + let area = map_area(Some(rect_poly([x, y, x + w, y + h])), mul(self.xf(), cv.device_affine())); + self.combine(flags, area) + } + 0x4033 => { + let area = map_area(Some(area_of_ops(&self.path(id)?)), mul(self.xf(), cv.device_affine())); + self.combine(flags, area) + } + 0x4034 => { + let Some(Some(Obj::Region(reg))) = self.objects.get(id).cloned() else { return None }; + let area = map_area(reg, mul(self.xf(), cv.device_affine())); + self.combine(flags, area) + } + 0x4035 => { + let [dx, dy] = floats::<2>(r, 0)?; + let a = mul(self.xf(), cv.device_affine()); + let (ox, oy) = (dx * a[0] + dy * a[2], dx * a[1] + dy * a[3]); + if let Some(c) = self.clip.as_mut() { + for p in c.iter_mut() { + *p = (p.0 + ox, p.1 + oy); + } + } + Some(()) + } + 0x4010 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let [start, sweep] = floats::<2>(r, 4)?; + let rect = *rects(r, 12, 1, compressed)?.first()?; + self.painter().fill(cv, &arc_ops(rect, start, sweep, true), &fill); + Some(()) + } + 0x4011 | 0x4012 => { + let pen = self.pen(id)?; + let [start, sweep] = floats::<2>(r, 0)?; + let rect = *rects(r, 8, 1, compressed)?.first()?; + self.painter().stroke(cv, &arc_ops(rect, start, sweep, typ == 0x4011), pen); + Some(()) + } _ => None, } } @@ -150,35 +486,73 @@ impl Plus { fn push(&mut self, id: u32) { if self.stack.len() < MAX_STACK { - self.stack.push((id, self.world, self.page)); + self.stack.push(Saved { id, world: self.world, page: self.page, clip: self.clip.clone() }); } } fn pop(&mut self, id: u32) { - if let Some(i) = self.stack.iter().rposition(|s| s.0 == id) { - let (_, w, p) = self.stack[i]; + if let Some(i) = self.stack.iter().rposition(|s| s.id == id) { + let s = self.stack.swap_remove(i); + (self.world, self.page, self.clip) = (s.world, s.page, s.clip); self.stack.truncate(i); - self.world = w; - self.page = p; } } - fn object(&mut self, flags: u16, r: Bytes) -> Option<()> { - if flags & 0x8000 != 0 { - // Continued (split) objects are images and large paths; not read yet. - return None; - } - let id = usize::from(flags & 0xFF); - let obj = match (flags >> 8) & 0x7F { - 1 => brush(r, 0).map(|(c, _)| Obj::Brush(c)), - 2 => pen(r), - 3 => path(r).map(Obj::Path), - _ => None, + /// Combines a new clip area with the current one under the record's combine mode: replace, + /// intersect and union (as the rectangle around both). XOR, exclude and complement leave the + /// clip as it was. + fn combine(&mut self, flags: u16, new: Area) -> Option<()> { + self.clip = match (flags >> 8) & 0xF { + 0 => new, + 1 => area_intersect(self.clip.take(), new), + 2 => area_union(self.clip.take(), new), + _ => self.clip.take(), }; + Some(()) + } + + fn object(&mut self, flags: u16, r: Bytes) -> Option<()> { + let id = usize::from(flags & 0xFF); + let kind = (flags >> 8) & 0x7F; + if flags & 0x8000 != 0 { + // A continued object: the total size, then this record's share of the bytes. + let total = (r.u32(0)? as usize).min(MAX_OBJECT_BYTES); + let chunk = r.tail(4)?.0; + let p = match self.partial.take() { + Some(p) if p.id == id && p.kind == kind && p.total == total => p, + _ => Partial { id, kind, total, data: Vec::new() }, + }; + let mut p = p; + let room = p.total.saturating_sub(p.data.len()); + p.data.extend_from_slice(&chunk[..chunk.len().min(room)]); + if p.data.len() >= p.total { + let obj = parse_object(p.kind, Bytes(&p.data)); + self.store(p.id, obj); + } else { + self.partial = Some(p); + } + return Some(()); + } + // The last piece of a continued object comes without the continue flag. + if let Some(mut p) = self.partial.take() + && p.id == id + && p.kind == kind + { + let room = p.total.saturating_sub(p.data.len()); + p.data.extend_from_slice(&r.0[..r.0.len().min(room)]); + let obj = parse_object(kind, Bytes(&p.data)); + self.store(id, obj); + return Some(()); + } + let obj = parse_object(kind, r); + self.store(id, obj); + Some(()) + } + + fn store(&mut self, id: usize, obj: Option) { if let Some(slot) = self.objects.get_mut(id) { *slot = obj; } - Some(()) } fn path(&self, id: usize) -> Option> { @@ -188,88 +562,315 @@ impl Plus { } } - /// The brush colour of a fill record: an ARGB value with the `S` flag, else a brush object. - fn brush_of(&self, flags: u16, v: u32) -> Option<[u8; 4]> { - if flags & 0x8000 != 0 { - return Some(argb(v)); - } - match self.objects.get(v as usize)? { - Some(Obj::Brush(c)) => Some(*c), + fn pen(&self, id: usize) -> Option<([u8; 4], f64)> { + match self.objects.get(id)? { + Some(Obj::Pen { color, width }) => Some((*color, *width)), _ => None, } } - fn with_pen(&mut self, id: usize, f: impl FnOnce(&mut Painter) -> Option) -> Option { - let Some(Some(Obj::Pen { color, width })) = self.objects.get(id).cloned() else { return None }; - let mut p = Painter { xf: mul(self.world, [self.page, 0.0, 0.0, self.page, 0.0, 0.0]), pen: Some((color, width)), fill: None }; - f(&mut p) + fn image(&self, id: usize) -> Option> { + match self.objects.get(id)? { + Some(Obj::Image(img)) => Some(img.clone()), + _ => None, + } } - fn filler(&self, color: [u8; 4]) -> Painter { - Painter { xf: mul(self.world, [self.page, 0.0, 0.0, self.page, 0.0, 0.0]), pen: None, fill: Some(color) } + /// The brush of a fill record: an ARGB value with the `S` flag, else a brush object. + fn brush_of(&self, flags: u16, v: u32) -> Option { + if flags & 0x8000 != 0 { + return Some(Fill::Solid(argb(v))); + } + match self.objects.get(v as usize)? { + Some(Obj::Brush(f)) => Some(f.clone()), + _ => None, + } } - fn fill_rects(&mut self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { - let c = self.brush_of(flags, r.u32(0)?)?; - let rs = rects(r, 8, r.u32(4)? as usize, flags & 0x4000 != 0)?; - let p = self.filler(c); - for rc in rs { - p.shape(cv, rect_ops(rc), true, false); + /// DrawImage and DrawImagePoints: the source rectangle (pixels) cut from the image, drawn + /// into world rectangle `dst` = `[x, y, w, h]`. + fn draw_image(&self, cv: &mut Canvas, img: &ImageData, r: Bytes, dst: [f64; 4]) -> Option<()> { + let img = match img { + ImageData::Bitmap(b) => b, + ImageData::Meta(pic) => { + self.painter().picture(cv, dst, pic); + return Some(()); + } + }; + let [sx, sy, sw, sh] = floats::<4>(r, 8)?; + let src = if r.u32(4)? == 2 { [sx, sy, sw, sh].map(|v| v.round().clamp(-1.0e9, 1.0e9) as i64) } else { [0; 4] }; + let (part, dst) = crate::dib::clip_blit(img, src, dst)?; + self.painter().image(cv, dst, part); + Some(()) + } + + fn font(&self, id: usize) -> Option { + match self.objects.get(id)? { + Some(Obj::Font(f)) => Some(f.clone()), + _ => None, + } + } + + /// The font as a LOGFONT whose height is in world units. + fn log_font(&self, f: &FontObj) -> LogFont { + let em = if f.unit == 0 { f.em } else { f.em * unit_px(f.unit, self.dpi) / self.page }; + let font = Font { + family: f.family.clone(), + height: -(em.clamp(0.0, 1.0e6) as f32), + weight: if f.style & 1 != 0 { 700 } else { 400 }, + italic: f.style & 2 != 0, + underline: f.style & 4 != 0, + strike: f.style & 8 != 0, + ..Font::default() + }; + LogFont { font, escapement: 0.0 } + } + + /// DrawString: lines of text laid out in a rectangle by the string format's alignments. + fn draw_string(&self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { + let font = self.font(usize::from(flags & 0xFF))?; + let color = self.brush_of(flags, r.u32(0)?)?.color(); + let fmt = match self.objects.get(r.u32(4)? as usize) { + Some(Some(Obj::Format(f))) => *f, + _ => Format::default(), + }; + let len = (r.u32(8)? as usize).min(MAX_CHARS); + let [x, y, w, h] = floats::<4>(r, 12)?; + let units: Vec = (0..len).map(|i| r.u16(28 + i * 2)).collect::>()?; + let lf = self.log_font(&font); + let em = f64::from(-lf.font.height); + let wrap = w > 0.0 && fmt.flags & 0x1000 == 0; + let lines: Vec> = units + .split(|u| *u == 0x0A) + .flat_map(|l| { + let l: Vec = l.iter().copied().filter(|u| *u != 0x0D).collect(); + if wrap { wrap_line(&l, w, em) } else { vec![l] } + }) + .collect(); + let line_h = em * 1.2; + let (ax, ta_h) = match fmt.align { + 1 => (x + w / 2.0, TA_CENTER), + 2 => (x + w, TA_RIGHT), + _ => (x, 0), + }; + let block = line_h * lines.len() as f64; + let top = match fmt.line_align { + 1 => y + (h - block) / 2.0, + 2 => y + h - block, + _ => y, + }; + let clip = (w > 0.0 && h > 0.0 && fmt.flags & 0x4000 == 0).then_some([x, y, x + w, y + h]); + let p = self.painter(); + for (i, line) in lines.iter().enumerate() { + if line.is_empty() { + continue; + } + let (text, dx) = crate::text::decode_utf16(line, &[]); + let at = (ax, top + line_h * i as f64); + let (at, ta) = if fmt.line_align == 2 && lines.len() == 1 { ((ax, y + h), ta_h | TA_BOTTOM) } else { (at, ta_h) }; + p.text(cv, at, text, dx, TextStyle { color, font: lf.clone(), align: ta, clip }, IDENTITY); } Some(()) } - fn fill_polygon(&mut self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { - let c = self.brush_of(flags, r.u32(0)?)?; - let pts = points(r, 8, r.u32(4)? as usize, flags & 0x4000 != 0)?; - self.filler(c).shape(cv, poly_ops(&pts, true), true, false); - Some(()) - } - - fn fill_ellipse(&mut self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { - let c = self.brush_of(flags, r.u32(0)?)?; - let rect = *rects(r, 4, 1, flags & 0x4000 != 0)?.first()?; - self.filler(c).shape(cv, ellipse_ops(rect), true, false); - Some(()) - } - - fn fill_path(&mut self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { - let c = self.brush_of(flags, r.u32(0)?)?; - let ops = self.path(usize::from(flags & 0xFF))?; - self.filler(c).shape(cv, ops, true, false); + /// DrawDriverString with character codes (CmapLookup): positioned glyphs on a baseline. + fn draw_driver_string(&self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { + let font = self.font(usize::from(flags & 0xFF))?; + let color = self.brush_of(flags, r.u32(0)?)?.color(); + let opts = r.u32(4)?; + let n = (r.u32(12)? as usize).min(MAX_CHARS); + let mut units: Vec = (0..n).map(|i| r.u16(16 + i * 2)).collect::>()?; + if opts & 1 == 0 { + // Glyph indices into the font. The crate knows no fonts, but the classic Windows core + // fonts keep the standard Macintosh glyph order for ASCII, so those map back. + units = glyphs_to_chars(&font.family, &units)?; + } + let pos = points(r, 16 + n * 2, n, false)?; + let m = if r.u32(8)? != 0 { matrix(r, 16 + n * 10)? } else { IDENTITY }; + let lf = self.log_font(&font); + let first = *pos.first()?; + let dx: Vec = if opts & 4 != 0 { + Vec::new() + } else { + let mut d: Vec = pos.windows(2).map(|w| (w[1].0 - w[0].0) as f32).collect(); + d.push(d.last().copied().unwrap_or(-lf.font.height * 0.5)); + d + }; + let (text, dx) = crate::text::decode_utf16(&units, &dx); + self.painter().text(cv, first, text, dx, TextStyle { color, font: lf, align: TA_BASELINE, clip: None }, m); Some(()) } } -/// Draws through the canvas with EMF+'s transform and its own pen or brush, then puts the +/// Page unit to device pixels: world, display and pixel are 1; point, inch, document, mm. +fn unit_px(unit: u32, dpi: f64) -> f64 { + match unit { + 3 => dpi / 72.0, + 4 => dpi, + 5 => dpi / 300.0, + 6 => dpi / 25.4, + _ => 1.0, + } +} + +struct TextStyle { + color: [u8; 4], + font: LogFont, + align: u32, + /// World clip rectangle `[l, t, r, b]`. + clip: Option<[f64; 4]>, +} + +/// Draws through the canvas with EMF+'s transform, clip and its own pen or brush, then puts the /// canvas's GDI state back. struct Painter { xf: Xform, - pen: Option<([u8; 4], f64)>, - fill: Option<[u8; 4]>, + clip: Area, } impl Painter { - fn shape(&self, cv: &mut Canvas, ops: Vec, fill: bool, stroke: bool) { + fn begin(&self, cv: &mut Canvas, pre: Xform) { let world = cv.world(); cv.save_dc(); - cv.modify_world(mul(self.xf, world), 4); - let (pc, pw) = self.pen.unwrap_or(([0, 0, 0, 0], 0.0)); - cv.set_pen(Pen { style: if self.pen.is_some() { 0 } else { PS_NULL }, width: pw, color: pc }); - cv.set_brush(match self.fill { - Some(c) => Brush::new(BS_SOLID, c), - None => Brush::new(BS_NULL, [0, 0, 0, 0]), - }); - cv.shape(&ops, fill, stroke); - cv.restore_dc(-1); + cv.modify_world(mul(mul(pre, self.xf), world), 4); + cv.set_clip(self.clip.clone()); } - fn draw_rects(&self, cv: &mut Canvas, compressed: bool, r: Bytes) -> Option<()> { - for rc in rects(r, 4, r.u32(0)? as usize, compressed)? { - self.shape(cv, rect_ops(rc), false, true); - } - Some(()) + fn end(&self, cv: &mut Canvas) { + cv.restore_dc(-1); + cv.set_clip(None); } + + fn no_pen(cv: &mut Canvas) { + cv.set_pen(Pen { style: PS_NULL, width: 0.0, color: [0, 0, 0, 0] }); + } + + fn fill(&self, cv: &mut Canvas, ops: &[Op], fill: &Fill) { + self.begin(cv, IDENTITY); + Self::no_pen(cv); + match fill { + Fill::Solid(c) => { + cv.set_brush(Brush::new(BS_SOLID, *c)); + cv.shape(ops, true, false); + } + Fill::Hatch { style, fg, bg } => match hatch_tile(*style, *fg, Some(*bg)) { + Some(px) => cv.tile_shape(ops, Arc::new(RawTile { w: 8, h: 8, px, cell: cv.pattern_px })), + // GDI+ has many more hatch styles than GDI: those fill with the blend of both colours. + None => { + cv.set_brush(Brush::new(BS_SOLID, lerp(*fg, *bg, 0.5))); + cv.shape(ops, true, false); + } + }, + _ => { + // Device point to brush space: invert brush space → world → device. + if let Some(inv) = invert(mul(fill.xf(), cv.device_affine())) { + cv.shade_shape(ops, &|p| fill.sample(apply(inv, p))); + } + } + } + self.end(cv); + } + + fn stroke(&self, cv: &mut Canvas, ops: &[Op], (color, width): ([u8; 4], f64)) { + self.begin(cv, IDENTITY); + cv.set_pen(Pen { style: 0, width, color }); + cv.set_brush(Brush::new(BS_NULL, [0, 0, 0, 0])); + cv.shape(ops, false, true); + self.end(cv); + } + + fn image(&self, cv: &mut Canvas, dst: [f64; 4], img: Rgba) { + self.begin(cv, IDENTITY); + cv.bitmap(dst, img); + self.end(cv); + } + + /// A nested metafile's picture stretched into world rectangle `dst` = `[x, y, w, h]`. + fn picture(&self, cv: &mut Canvas, [x, y, w, h]: [f64; 4], pic: &Picture) { + self.begin(cv, IDENTITY); + let (sx, sy) = (w / f64::from(pic.width), h / f64::from(pic.height)); + let m = mul([sx, 0.0, 0.0, sy, x, y], cv.device_affine()); + let scale = (m[0] * m[3] - m[1] * m[2]).abs().sqrt(); + let pt = |a: f32, b: f32| apply(m, (f64::from(a), f64::from(b))); + let ops = |segs: &[Seg]| -> Vec { + segs.iter() + .map(|s| match *s { + Seg::Move(a, b) => Op::M(pt(a, b)), + Seg::Line(a, b) => Op::L(pt(a, b)), + Seg::Cubic(a, b, c, d, e, f) => Op::C(pt(a, b), pt(c, d), pt(e, f)), + Seg::Close => Op::Z, + }) + .collect() + }; + // The nested picture's own clip regions are not carried over: each item takes the + // canvas's current clip. + for item in &pic.items { + if cv.full() { + break; + } + let raw = match item { + Item::Path { segs, fill, stroke, even_odd, .. } => Raw::Path { + ops: ops(segs), + fill: *fill, + stroke: stroke.map(|(c, w)| (c, f64::from(w) * scale)), + even_odd: *even_odd, + region: None, + }, + Item::Pattern { segs, even_odd, tile, .. } => Raw::Pattern { + ops: ops(segs), + even_odd: *even_odd, + tile: Arc::new(RawTile { + w: tile.width as usize, + h: tile.height as usize, + px: tile.rgba.clone(), + cell: f64::from(tile.cell[0]) * scale, + }), + region: None, + }, + Item::Bitmap { rect, width, height, rgba, .. } => { + let (a, b) = (pt(rect[0], rect[1]), pt(rect[0] + rect[2], rect[1] + rect[3])); + let mut img = Rgba { w: *width as usize, h: *height as usize, bottom_up: false, px: rgba.clone() }; + crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1); + Raw::Bitmap { rect: [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()], img, region: None } + } + Item::Text(t) => { + let mut t = t.clone(); + t.transform = mul(t.transform, m); + if let Some([x0, y0, x1, y1]) = t.clip { + let (p, q) = (pt(x0, y0), pt(x1, y1)); + t.clip = Some([p.0.min(q.0) as f32, p.1.min(q.1) as f32, p.0.max(q.0) as f32, p.1.max(q.1) as f32]); + } + Raw::Text { text: t, region: None } + } + }; + cv.push_device(raw); + } + self.end(cv); + } + + fn text(&self, cv: &mut Canvas, at: P, text: String, dx: Vec, style: TextStyle, pre: Xform) { + self.begin(cv, pre); + cv.set_text_style(style.color, style.font, style.align); + let (opts, rect) = match style.clip { + Some(c) => (ETO_CLIPPED, Some(c)), + None => (0, None), + }; + cv.text(at, text, dx, opts, rect); + self.end(cv); + } +} + +fn apply(m: Xform, p: P) -> P { + (p.0 * m[0] + p.1 * m[2] + m[4], p.0 * m[1] + p.1 * m[3] + m[5]) +} + +fn invert(m: Xform) -> Option { + let det = m[0] * m[3] - m[1] * m[2]; + if !det.is_finite() || det.abs() < 1e-12 { + return None; + } + let (a, b, c, d) = (m[3] / det, -m[1] / det, -m[2] / det, m[0] / det); + Some([a, b, c, d, -(m[4] * a + m[5] * c), -(m[4] * b + m[5] * d)]) } /// ARGB (0xAARRGGBB) to RGBA. @@ -277,21 +878,103 @@ fn argb(v: u32) -> [u8; 4] { [(v >> 16) as u8, (v >> 8) as u8, v as u8, (v >> 24) as u8] } -fn matrix(r: Bytes, off: usize) -> Option { - let mut m = [0.0; 6]; - for (i, v) in m.iter_mut().enumerate() { - *v = f64::from(r.f32(off + i * 4)?); +fn floats(r: Bytes, off: usize) -> Option<[f64; N]> { + let mut v = [0.0; N]; + for (i, x) in v.iter_mut().enumerate() { + *x = f64::from(r.f32(off + i * 4)?); } - m.iter().all(|v| v.is_finite()).then_some(m) + v.iter().all(|x| x.is_finite()).then_some(v) } -/// A brush object at `off`: its colour and byte length. Only solid brushes are read; other kinds -/// (hatch, texture, gradients) are given their first colour so shapes still show. -fn brush(r: Bytes, off: usize) -> Option<([u8; 4], usize)> { - let kind = r.u32(off + 4)?; - let c = argb(r.u32(off + 8)?); - let c = if kind == 1 { argb(r.u32(off + 12)?) } else { c }; - Some((c, 12)) +fn matrix(r: Bytes, off: usize) -> Option { + floats::<6>(r, off) +} + +fn parse_object(kind: u16, r: Bytes) -> Option { + match kind { + 1 => brush(r, 0).map(Obj::Brush), + 2 => pen(r), + 3 => path(r).map(Obj::Path), + 4 => region(r), + 5 => image(r).map(|i| Obj::Image(Arc::new(i))), + 6 => font(r), + 7 => Some(Obj::Format(Format { flags: r.u32(4)?, align: r.u32(12)?, line_align: r.u32(16)? })), + _ => None, + } +} + +/// A brush object at `off`: version, type, then the type's data. +fn brush(r: Bytes, off: usize) -> Option { + let d = r.tail(off + 8)?; + match r.u32(off + 4)? { + 0 => Some(Fill::Solid(argb(d.u32(0)?))), + 1 => Some(Fill::Hatch { style: d.u32(0)?, fg: argb(d.u32(4)?), bg: argb(d.u32(8)?) }), + 2 => { + let bflags = d.u32(0)?; + let wrap = d.u32(4)?; + let (xf, off) = if bflags & 0x2 != 0 { (matrix(d, 8)?, 32) } else { (IDENTITY, 8) }; + let ImageData::Bitmap(img) = image(d.tail(off)?)? else { return None }; + Some(Fill::Texture { img: Arc::new(img), wrap, xf }) + } + 3 => path_gradient(d), + 4 => linear_gradient(d), + _ => None, + } +} + +fn linear_gradient(d: Bytes) -> Option { + let bflags = d.u32(0)?; + let wrap = d.u32(4)?; + let rect = floats::<4>(d, 8)?; + let (c0, c1) = (argb(d.u32(24)?), argb(d.u32(28)?)); + let mut off = 40; + let mut xf = IDENTITY; + if bflags & 0x2 != 0 { + xf = matrix(d, off)?; + off += 24; + } + let mut stops = vec![(0.0, c0), (1.0, c1)]; + if bflags & 0x4 != 0 { + // Preset colours: positions, then one colour each. + let n = (d.u32(off)? as usize).min(MAX_STOPS); + let pos: Vec = (0..n).map(|i| d.f32(off + 4 + i * 4).map(f64::from)).collect::>()?; + let cols: Vec<[u8; 4]> = (0..n).map(|i| d.u32(off + 4 + n * 4 + i * 4).map(argb)).collect::>()?; + stops = pos.into_iter().zip(cols).collect(); + } else if bflags & 0x8 != 0 { + // Blend factors: positions, then how far from the start colour to the end colour. + let n = (d.u32(off)? as usize).min(MAX_STOPS); + let pos: Vec = (0..n).map(|i| d.f32(off + 4 + i * 4).map(f64::from)).collect::>()?; + let fac: Vec = (0..n).map(|i| d.f32(off + 4 + n * 4 + i * 4).map(f64::from)).collect::>()?; + stops = pos.into_iter().zip(fac).map(|(p, k)| (p, lerp(c0, c1, k.clamp(0.0, 1.0)))).collect(); + } + stops.retain(|s| s.0.is_finite()); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + (!stops.is_empty()).then_some(Fill::Linear { rect, stops, wrap, xf }) +} + +/// A path gradient: from the centre colour to the (first) surrounding colour at the boundary, +/// taken as the ellipse in the boundary's bounding rectangle. +fn path_gradient(d: Bytes) -> Option { + let bflags = d.u32(0)?; + let center_c = argb(d.u32(8)?); + let [cx, cy] = floats::<2>(d, 12)?; + let nc = (d.u32(20)? as usize).min(MAX_STOPS); + let surround = if nc > 0 { argb(d.u32(24)?) } else { center_c }; + let mut off = 24 + nc * 4; + let bbox = if bflags & 0x1 != 0 { + let size = d.u32(off)? as usize; + let b = ops_bbox(&path(d.tail(off + 4)?)?)?; + off = off.checked_add(4 + size)?; + b + } else { + let n = d.u32(off)? as usize; + let pts = points(d, off + 4, n, false)?; + off = off.checked_add(4 + 8 * n.min(MAX_POINTS))?; + ops_bbox(&poly_ops(&pts, true))? + }; + let xf = if bflags & 0x2 != 0 { matrix(d, off)? } else { IDENTITY }; + let radius = ((bbox[2] - bbox[0]) / 2.0, (bbox[3] - bbox[1]) / 2.0); + Some(Fill::Radial { center: (cx, cy), radius, stops: vec![(0.0, center_c), (1.0, surround)], xf }) } /// A pen object: version, type, then pen data (flags, unit, width, optional fields), then a brush. @@ -314,7 +997,7 @@ fn pen(r: Bytes) -> Option { off += 4 + (r.u32(off)? as usize).min(1 << 20); } } - let (color, _) = brush(r, off)?; + let color = brush(r, off)?.color(); Some(Obj::Pen { color, width: if width.is_finite() { width.clamp(0.0, 1.0e5) } else { 0.0 } }) } @@ -338,12 +1021,10 @@ fn path(r: Bytes) -> Option> { 0 => ops.push(Op::M(p)), 3 if i + 2 < n => { ops.push(Op::C(p, pts[i + 1], pts[i + 2])); - let last = types[i + 2]; - i += 2; - if last & 0x80 != 0 { + if types[i + 2] & 0x80 != 0 { ops.push(Op::Z); } - i += 1; + i += 3; continue; } _ => ops.push(Op::L(p)), @@ -356,6 +1037,259 @@ fn path(r: Bytes) -> Option> { Some(ops) } +/// A region object as a convex area in world units (non-convex parts become their bounding +/// rectangle). +fn region(r: Bytes) -> Option { + region_node(r, 8, 0).map(|(a, _)| Obj::Region(a)) +} + +/// One region node at `off`: its area and its length in bytes. +fn region_node(r: Bytes, off: usize, depth: usize) -> Option<(Area, usize)> { + if depth > MAX_REGION_DEPTH { + return None; + } + match r.u32(off)? { + 0x1000_0000 => { + let [x, y, w, h] = floats::<4>(r, off + 4)?; + Some((Some(rect_poly([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])), 20)) + } + 0x1000_0001 => { + let size = r.u32(off + 4)? as usize; + let area = path(r.tail(off + 8)?).map(|ops| area_of_ops(&ops)).unwrap_or_default(); + Some((Some(area), 8usize.checked_add(size)?)) + } + 0x1000_0002 => Some((Some(Vec::new()), 4)), + 0x1000_0003 => Some((None, 4)), + mode @ 0..=5 => { + let (a, la) = region_node(r, off + 4, depth + 1)?; + let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1)?; + let out = match mode { + 0 => b, + 1 => area_intersect(a, b), + 2 => area_union(a, b), + // XOR, exclude, complement: the left area bounds the result closely enough. + _ => a, + }; + Some((out, (4 + la).checked_add(lb)?)) + } + _ => None, + } +} + +/// The area a path encloses: its outline when that is one convex figure, else the rectangle +/// around it. +fn area_of_ops(ops: &[Op]) -> Vec

{ + let subs = flatten(ops); + if let [(pts, _)] = subs.as_slice() + && let Some(c) = convex(pts) + { + return c; + } + ops_bbox(ops).map(rect_poly).unwrap_or_default() +} + +fn area_intersect(a: Area, b: Area) -> Area { + match (a, b) { + (None, x) | (x, None) => x, + (Some(a), Some(b)) if a.len() >= 3 && b.len() >= 3 => Some(convex(&clip_polygon(&a, &b)).unwrap_or_default()), + _ => Some(Vec::new()), + } +} + +fn area_union(a: Area, b: Area) -> Area { + match (a, b) { + (Some(a), Some(b)) => { + if a.len() < 3 { + return Some(b); + } + if b.len() < 3 { + return Some(a); + } + let all: Vec

= a.into_iter().chain(b).collect(); + Some(pts_bbox(&all).map(rect_poly).unwrap_or_default()) + } + _ => None, + } +} + +/// An area through an affine map (which keeps it convex; the orientation is put right again). +fn map_area(a: Area, m: Xform) -> Area { + a.map(|pts| { + let mapped: Vec

= pts.iter().map(|p| apply(m, *p)).collect(); + if mapped.len() < 3 { Vec::new() } else { convex(&mapped).unwrap_or_default() } + }) +} + +/// An elliptical arc in `rect` from `start` sweeping `sweep` degrees (clockwise on screen), as +/// cubics; a pie adds the lines to and from the centre. +fn arc_ops([l, t, r, b]: [f64; 4], start: f64, sweep: f64, pie: bool) -> Vec { + let (cx, cy, rx, ry) = ((l + r) / 2.0, (t + b) / 2.0, (r - l) / 2.0, (b - t) / 2.0); + let sweep = sweep.clamp(-360.0, 360.0); + // GDI+ angles are along rays from the centre; turn them into the ellipse's parameter. + let param = |deg: f64| { + let a = deg.to_radians(); + (rx * a.sin()).atan2(ry * a.cos()) + }; + let t0 = param(start); + let mut dt = if sweep.abs() >= 360.0 { sweep.signum() * std::f64::consts::TAU } else { param(start + sweep) - t0 }; + if sweep.abs() < 360.0 { + while sweep > 0.0 && dt < 0.0 { + dt += std::f64::consts::TAU; + } + while sweep < 0.0 && dt > 0.0 { + dt -= std::f64::consts::TAU; + } + } + let at = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin()); + let mut ops = Vec::with_capacity(8); + if pie { + ops.push(Op::M((cx, cy))); + ops.push(Op::L(at(t0))); + } else { + ops.push(Op::M(at(t0))); + } + let n = ((dt.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).clamp(1, 4); + let step = dt / n as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + for i in 0..n { + let (a, e) = (t0 + step * i as f64, t0 + step * (i + 1) as f64); + let (p0, p3) = (at(a), at(e)); + let c1 = (p0.0 - k * rx * a.sin(), p0.1 + k * ry * a.cos()); + let c2 = (p3.0 + k * rx * e.sin(), p3.1 - k * ry * e.cos()); + ops.push(Op::C(c1, c2, p3)); + } + if pie { + ops.push(Op::Z); + } + ops +} + +/// A font object: em size, unit, style flags, then the family name in UTF-16. +fn font(r: Bytes) -> Option { + let em = f64::from(r.f32(4)?); + let n = (r.u32(20)? as usize).min(64); + let units: Vec = (0..n).map(|i| r.u16(24 + i * 2)).collect::>()?; + let family = String::from_utf16_lossy(&units).trim_end_matches('\0').to_string(); + em.is_finite().then_some(Obj::Font(FontObj { em, unit: r.u32(8)?, style: r.u32(12)?, family })) +} + +/// An image object: a bitmap of raw pixels or a compressed (PNG, JPEG, GIF…) file, or a nested +/// metafile. +fn image(r: Bytes) -> Option { + match r.u32(4)? { + 1 => bitmap(r).map(ImageData::Bitmap), + 2 => metafile(r).map(ImageData::Meta), + _ => None, + } +} + +/// A metafile image: type, size, then a WMF (placeable or not) or EMF file, parsed as a picture. +fn metafile(r: Bytes) -> Option { + let size = r.u32(12)? as usize; + let data = r.slice(16, size).or_else(|| r.tail(16).map(|t| t.0))?; + let depth = NESTING.with(|n| n.get()); + if depth >= MAX_NESTING { + return None; + } + NESTING.with(|n| n.set(depth + 1)); + let pic = crate::parse(data).ok(); + NESTING.with(|n| n.set(depth)); + pic.filter(|p| p.width > 0.0 && p.height > 0.0) +} + +fn bitmap(r: Bytes) -> Option { + let (w, h, stride) = (r.u32(8)? as i32, r.u32(12)? as i32, r.u32(16)? as i32); + let format = r.u32(20)?; + let data = r.tail(28)?; + if r.u32(24)? == 1 { + return decode_compressed(data.0); + } + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + if w == 0 || h == 0 || w.checked_mul(h)? > crate::MAX_PIXELS { + return None; + } + let bpp = ((format >> 8) & 0xFF) as usize; + let (pal, pixels) = if format & 0x0001_0000 != 0 { + // Indexed formats carry a palette first: flags, count, ARGB entries. + let n = (data.u32(4)? as usize).min(256); + let pal: Vec<[u8; 4]> = (0..n).map(|i| data.u32(8 + i * 4).map(argb)).collect::>()?; + (pal, data.tail(8 + n * 4)?) + } else { + (Vec::new(), data) + }; + let row = (w.checked_mul(bpp)?).div_ceil(8); + let stride_abs = (stride.unsigned_abs() as usize).max(row); + let mut px = Vec::with_capacity(w * h * 4); + for y in 0..h { + let line = pixels.slice(y.checked_mul(stride_abs)?, row)?; + for x in 0..w { + let c = match (format, bpp) { + (_, 1 | 4 | 8) if !pal.is_empty() => { + let bit = x * bpp; + let byte = *line.get(bit / 8)?; + let i = (byte >> (8 - bpp - bit % 8)) & ((1u16 << bpp) - 1) as u8; + pal.get(usize::from(i)).copied().unwrap_or([0, 0, 0, 255]) + } + (_, 24) => { + let p = line.get(x * 3..x * 3 + 3)?; + [p[2], p[1], p[0], 255] + } + (0x0002_2009, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + [p[2], p[1], p[0], 255] + } + (0x000E_200B, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + let a = u32::from(p[3]); + let un = |v: u8| (u32::from(v) * 255 + a / 2).checked_div(a).map_or(v, |c| c.min(255) as u8); + [un(p[2]), un(p[1]), un(p[0]), p[3]] + } + (_, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + [p[2], p[1], p[0], p[3]] + } + (0x0002_1006, 16) => { + let v = u16::from_le_bytes([*line.get(x * 2)?, *line.get(x * 2 + 1)?]); + let s = |v: u16, bits: u32| ((u32::from(v) * 255) / ((1 << bits) - 1)) as u8; + [s(v >> 11, 5), s((v >> 5) & 0x3F, 6), s(v & 0x1F, 5), 255] + } + (_, 16) => { + let v = u16::from_le_bytes([*line.get(x * 2)?, *line.get(x * 2 + 1)?]); + let s = |v: u16| ((u32::from(v & 0x1F) * 255) / 31) as u8; + [s(v >> 10), s(v >> 5), s(v), 255] + } + _ => return None, + }; + px.extend_from_slice(&c); + } + } + // A negative stride stores the rows bottom-up. + Some(top_down(Rgba { w, h, bottom_up: stride < 0, px })) +} + +fn top_down(mut img: Rgba) -> Rgba { + if img.bottom_up { + crate::dib::flip(&mut img, false, true); + img.bottom_up = false; + } + img +} + +fn decode_compressed(bytes: &[u8]) -> Option { + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(1 << 15); + limits.max_image_height = Some(1 << 15); + limits.max_alloc = Some((crate::MAX_PIXELS * 4) as u64); + reader.limits(limits); + let img = reader.decode().ok()?.to_rgba8(); + let (w, h) = (img.width() as usize, img.height() as usize); + if w.checked_mul(h)? > crate::MAX_PIXELS { + return None; + } + Some(Rgba { w, h, bottom_up: false, px: img.into_raw() }) +} + /// `n` points at `off`: 16-bit integers when compressed, else 32-bit floats. fn points(r: Bytes, off: usize, n: usize, compressed: bool) -> Option> { let n = n.min(MAX_POINTS); @@ -384,6 +1318,38 @@ fn rect_ops([l, t, r, b]: [f64; 4]) -> Vec { crate::geom::rect(l, t, r, b) } +fn finite(v: f32) -> Option { + v.is_finite().then_some(f64::from(v)) +} + +/// A GDI+ cardinal spline through `pts` as cubic Béziers: each segment's control points lie a +/// third of `tension` (GDI+'s default 0.5) along the neighbouring chords. `closed` joins the +/// last point back to the first; an open curve draws `segments` segments from point `offset`. +fn curve_ops(pts: &[P], tension: f64, closed: bool, offset: usize, segments: usize) -> Vec { + let n = pts.len(); + if n < 2 { + return Vec::new(); + } + let k = tension.clamp(0.0, 10.0) / 3.0; + let at = |i: isize| -> P { + let i = if closed { i.rem_euclid(n as isize) as usize } else { i.clamp(0, n as isize - 1) as usize }; + pts.get(i).copied().unwrap_or((0.0, 0.0)) + }; + let (first, count) = if closed { (0, n) } else { (offset.min(n - 1), segments.min(n - 1 - offset.min(n - 1))) }; + let mut ops = vec![Op::M(at(first as isize))]; + for s in first..first + count { + let i = s as isize; + let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2)); + let c1 = (p1.0 + (p2.0 - p0.0) * k, p1.1 + (p2.1 - p0.1) * k); + let c2 = (p2.0 - (p3.0 - p1.0) * k, p2.1 - (p3.1 - p1.1) * k); + ops.push(Op::C(c1, c2, p2)); + } + if closed { + ops.push(Op::Z); + } + ops +} + fn poly_ops(pts: &[P], close: bool) -> Vec { let mut ops: Vec = pts.iter().enumerate().map(|(i, p)| if i == 0 { Op::M(*p) } else { Op::L(*p) }).collect(); if close { @@ -406,3 +1372,68 @@ fn ellipse_ops([l, t, r, b]: [f64; 4]) -> Vec { Op::Z, ] } + +/// Core fonts whose first glyphs follow the standard Macintosh order (glyph 3 is the space, +/// glyphs 3..=97 are U+0020..=U+007E). +const MAC_ORDER_FONTS: &[&str] = + &["arial", "arial black", "times new roman", "courier new", "verdana", "georgia", "tahoma", "trebuchet ms", "comic sans ms", "impact"]; + +/// Glyph indices of a known font back to ASCII; None when the font is unknown or a glyph is +/// outside the ASCII range. +fn glyphs_to_chars(family: &str, glyphs: &[u16]) -> Option> { + let family = family.trim().to_ascii_lowercase(); + if !MAC_ORDER_FONTS.contains(&family.as_str()) { + return None; + } + glyphs.iter().map(|g| (3..=97).contains(g).then_some(g + 29)).collect() +} + +/// A rough advance for a character in ems: the metafile crate has no fonts, so line breaks for +/// wrapped strings use typical proportional widths. +fn char_em(u: u16) -> f64 { + match char::from_u32(u32::from(u)).unwrap_or('x') { + ' ' | 'i' | 'l' | 'j' | '.' | ',' | ':' | ';' | '\'' | '!' | '|' | 'I' | 'f' | 't' | 'r' => 0.3, + 'm' | 'w' | 'M' | 'W' => 0.85, + c if c.is_ascii_uppercase() || c.is_ascii_digit() => 0.62, + c if (c as u32) >= 0x2E80 => 1.0, + _ => 0.52, + } +} + +/// Greedy word wrap of one line to `width` world units at em size `em`; a word longer than the +/// line breaks between characters. +fn wrap_line(line: &[u16], width: f64, em: f64) -> Vec> { + if em <= 0.0 || !width.is_finite() { + return vec![line.to_vec()]; + } + let fits = |l: &[u16]| l.iter().map(|u| char_em(*u) * em).sum::() <= width; + let mut out: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + for word in line.split_inclusive(|u| *u == 0x20) { + let mut cand = cur.clone(); + cand.extend_from_slice(word); + let trimmed = |v: &[u16]| v.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1); + if fits(&cand[..trimmed(&cand)]) || cur.is_empty() && fits(&word[..trimmed(word)]) { + cur = cand; + continue; + } + if !cur.is_empty() { + let n = trimmed(&cur); + cur.truncate(n); + out.push(std::mem::take(&mut cur)); + } + // A word wider than the line on its own: break it between characters. + for &u in word { + cur.push(u); + if !fits(&cur[..trimmed(&cur)]) && cur.len() > 1 { + cur.pop(); + out.push(std::mem::take(&mut cur)); + cur.push(u); + } + } + } + let n = cur.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1); + cur.truncate(n); + out.push(cur); + out +} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index b1141a19..8e10cdf3 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -18,6 +18,7 @@ mod bytes; mod canvas; mod clip; +mod cliprect; mod dib; mod emf; mod emfplus; diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index e04e55c3..fb7dfe36 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1708,3 +1708,317 @@ fn emfplus_garbage_records_never_panic() { let _ = parse(&emf(&[plus_comment(&recs)], [0; 4], [0; 4])); } } + +fn plus_pic(recs: Vec>) -> Picture { + let mut all = vec![plus_header(false)]; + all.extend(recs); + parse(&emf(&[plus_comment(&all)], [0; 4], [0; 4])).unwrap() +} + +fn u16s(s: &str) -> Vec { + s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect() +} + +/// A 2 x 2 32bpp ARGB bitmap image object: red, green / blue, white. +fn bitmap_2x2() -> Vec { + let px = [0xFFFF_0000u32, 0xFF00_FF00, 0xFF00_00FF, 0xFFFF_FFFF]; + cat(vec![h32(0xDBC0_1002), h32(1), h32(2), h32(2), h32(8), h32(0x0026_200A), h32(0), px.iter().flat_map(|v| v.to_le_bytes()).collect()]) +} + +fn draw_image(id: u16, dst: [f32; 4]) -> Vec { + prec(0x401A, id, &cat(vec![h32(0), h32(2), f32s(&[0.0, 0.0, 2.0, 2.0]), f32s(&dst)])) +} + +#[test] +fn emfplus_draw_image_places_the_bitmap() { + let p = plus_pic(vec![prec(0x4008, 0x0500 | 4, &bitmap_2x2()), draw_image(4, [10.0, 10.0, 20.0, 40.0])]); + match &p.items[0] { + Item::Bitmap { rect, width, height, rgba, .. } => { + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rect, &[0.0, 0.0, 20.0, 40.0]); + assert_eq!(&rgba[..8], &[255, 0, 0, 255, 0, 255, 0, 255]); + assert_eq!(&rgba[8..12], &[0, 0, 255, 255]); + } + other => panic!("expected a bitmap: {other:?}"), + } +} + +#[test] +fn emfplus_object_split_across_records_is_joined() { + let obj = bitmap_2x2(); + let (a, b) = obj.split_at(20); + let total = h32(obj.len() as u32); + let first = prec(0x4008, 0x8000 | 0x0500 | 1, &cat(vec![total.clone(), a.to_vec()])); + let second = prec(0x4008, 0x0500 | 1, b); + let p = plus_pic(vec![first, second, draw_image(1, [0.0, 0.0, 4.0, 4.0])]); + assert!(matches!(&p.items[0], Item::Bitmap { width: 2, height: 2, .. })); +} + +#[test] +fn emfplus_compressed_png_image_decodes() { + let mut png = Vec::new(); + let img = image::RgbaImage::from_pixel(3, 1, image::Rgba([1, 2, 3, 255])); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let obj = cat(vec![h32(0xDBC0_1002), h32(1), h32(0), h32(0), h32(0), h32(0), h32(1), png]); + let draw = prec(0x401A, 2, &cat(vec![h32(0), h32(2), f32s(&[0.0, 0.0, 3.0, 1.0]), f32s(&[0.0, 0.0, 3.0, 1.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0500 | 2, &obj), draw]); + assert!(matches!(&p.items[0], Item::Bitmap { width: 3, height: 1, rgba, .. } if rgba[..4] == [1, 2, 3, 255])); +} + +fn font_obj(family: &str, em: f32, unit: u32, style: u32) -> Vec { + cat(vec![h32(0xDBC0_1002), f32s(&[em]), h32(unit), h32(style), h32(0), h32(family.encode_utf16().count() as u32), u16s(family)]) +} + +#[test] +fn emfplus_draw_string_with_font_and_centred_format() { + let font = prec(0x4008, 0x0600 | 3, &font_obj("Calibri", 12.0, 2, 1)); + // String format: centre alignment, no clip. + let fmt = prec(0x4008, 0x0700 | 4, &cat(vec![h32(0xDBC0_1002), h32(0x4000), h32(0), h32(1), h32(0)])); + let s = prec(0x401C, 0x8000 | 3, &cat(vec![h32(0xFF11_2233), h32(4), h32(2), f32s(&[0.0, 0.0, 100.0, 20.0]), u16s("Hi")])); + let p = plus_pic(vec![font, fmt, prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 100.0, 20.0])])), s]); + let t = p.items.iter().find_map(|i| if let Item::Text(t) = i { Some(t) } else { None }).expect("a text item"); + assert_eq!(t.text, "Hi"); + assert_eq!(t.font.family, "Calibri"); + assert_eq!(t.font.weight, 700); + assert!(close(t.font.height, -12.0)); + assert_eq!(t.color, [0x11, 0x22, 0x33, 0xFF]); + assert_eq!(t.h_align, HAlign::Center); + assert!(close(t.transform[4] as f32, 50.0)); + assert_eq!(t.background, None); + assert_eq!(t.clip, None); +} + +#[test] +fn emfplus_driver_string_uses_character_codes_and_positions() { + let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)); + let pos = f32s(&[5.0, 30.0, 11.0, 30.0]); + let s = prec(0x4036, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), h32(0), h32(2), u16s("Ab"), pos])); + let p = plus_pic(vec![prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 50.0, 50.0])])), font, s]); + let Some(Item::Text(t)) = p.items.get(1) else { panic!("expected text: {:?}", p.items) }; + assert_eq!(t.text, "Ab"); + assert_eq!(t.dx, vec![6.0, 6.0]); + assert_eq!(t.v_align, VAlign::Baseline); + assert!(close(t.transform[4] as f32, 5.0) && close(t.transform[5] as f32, 30.0)); +} + +#[test] +fn emfplus_glyph_index_driver_strings_map_back_in_core_fonts_only() { + // Glyph indices (no CmapLookup): 36, 69 are "Ab" in Arial's standard glyph order. + let gi = || prec(0x4036, 0x8000, &cat(vec![h32(0xFF00_0000), h32(0), h32(0), h32(2), h16(36), h16(69), f32s(&[0.0, 0.0, 6.0, 0.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)), gi()]); + assert!(matches!(&p.items[0], Item::Text(t) if t.text == "Ab"), "{:?}", p.items); + // An unknown font's glyph indices cannot be mapped: nothing is drawn. + assert!(plus_pic(vec![prec(0x4008, 0x0600, &font_obj("Calibri", 10.0, 0, 0)), gi()]).items.is_empty()); +} + +#[test] +fn emfplus_linear_gradient_fills_with_a_shaded_tile() { + // Red to blue across x = 0..100. + let brush = + cat(vec![h32(0xDBC0_1002), h32(4), h32(0), h32(0), f32s(&[0.0, 0.0, 100.0, 10.0]), h32(0xFFFF_0000), h32(0xFF00_00FF), h32(0), h32(0)]); + let fill = prec(0x400A, 0, &cat(vec![h32(5), h32(1), f32s(&[0.0, 0.0, 100.0, 10.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0100 | 5, &brush), fill]); + let Item::Pattern { tile, .. } = &p.items[0] else { panic!("expected a pattern: {:?}", p.items) }; + let px = |x: usize| &tile.rgba[x * 4..x * 4 + 4]; + assert!(px(0)[0] > 240 && px(0)[2] < 15, "left is red: {:?}", px(0)); + let right = (tile.width as usize - 2).min(127); + assert!(px(right)[2] > 230 && px(right)[0] < 25, "right is blue: {:?}", px(right)); +} + +#[test] +fn emfplus_hatch_brush_fills_with_an_8x8_tile() { + let brush = cat(vec![h32(0xDBC0_1002), h32(1), h32(4), h32(0xFF00_0000), h32(0xFFFF_FFFF)]); + let fill = prec(0x400A, 0, &cat(vec![h32(0), h32(1), f32s(&[0.0, 0.0, 16.0, 16.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0100, &brush), fill]); + assert!(matches!(&p.items[0], Item::Pattern { tile, .. } if tile.width == 8 && tile.height == 8)); +} + +#[test] +fn emfplus_clip_rect_cuts_fills_strokes_and_images() { + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 50.0, 50.0])); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[25.0, 25.0, 50.0, 50.0])])); + let outside = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[60.0, 60.0, 5.0, 5.0])])); + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF00_FF00, 1.0)); + let line = prec(0x400D, 1, &cat(vec![h32(2), f32s(&[0.0, 10.0, 100.0, 10.0])])); + let img = prec(0x4008, 0x0500 | 2, &bitmap_2x2()); + let p = plus_pic(vec![clip, fill, outside, pen, line, img, draw_image(2, [40.0, 0.0, 20.0, 20.0])]); + assert_eq!(p.items.len(), 3, "{:?}", p.items); + let (segs, ..) = path_segs(&p.items[0]); + let xs: Vec = segs.iter().filter_map(|s| if let Seg::Move(x, _) | Seg::Line(x, _) = s { Some(*x) } else { None }).collect(); + // Picture coordinates start at the drawing's left edge (0): the square is cut at x = 50. + assert!(xs.iter().all(|x| *x <= 50.0 + 1e-3), "{segs:?}"); + let (segs, ..) = path_segs(&p.items[1]); + assert!(matches!(segs[1], Seg::Line(x, _) if close(x, 50.0)), "{segs:?}"); + let Item::Bitmap { rect, width, .. } = &p.items[2] else { panic!() }; + assert_eq!(*width, 1); + assert!(close(rect[2], 10.0)); +} + +#[test] +fn emfplus_reset_clip_and_restore_bring_drawing_back() { + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 1.0, 1.0])); + let fill = || prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[10.0, 10.0, 5.0, 5.0])])); + let p = plus_pic(vec![prec(0x4025, 0, &h32(1)), clip, fill(), prec(0x4026, 0, &h32(1)), fill()]); + assert_eq!(p.items.len(), 1); + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 1.0, 1.0])); + let p = plus_pic(vec![clip, fill(), prec(0x4031, 0, &[]), fill()]); + assert_eq!(p.items.len(), 1); +} + +#[test] +fn emfplus_region_clip_intersects_rectangles() { + // Region: intersect(rect 0,0,30,30 ; rect 10,10,100,100) = 10..30. + let rect = |r: [f32; 4]| cat(vec![h32(0x1000_0000), f32s(&r)]); + let region = cat(vec![h32(0xDBC0_1002), h32(2), h32(1), rect([0.0, 0.0, 30.0, 30.0]), rect([10.0, 10.0, 90.0, 90.0])]); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]); + assert_eq!((p.width, p.height), (20.0, 20.0)); +} + +#[test] +fn emfplus_pie_and_arc() { + // A quarter pie from 0 to 90 degrees in a 0..100 circle: centre, then (100, 50) round to (50, 100). + let pie = prec(0x4010, 0x8000, &cat(vec![h32(0xFF00_0000), f32s(&[0.0, 90.0]), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![pie]); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0, 0, 0, 255])); + assert_eq!(segs[0], Seg::Move(0.0, 0.0)); + assert_eq!(segs[1], Seg::Line(50.0, 0.0)); + assert!(matches!(segs[2], Seg::Cubic(.., x, y) if close(x, 0.0) && close(y, 50.0)), "{segs:?}"); + assert_eq!(segs[3], Seg::Close); + // A half-circle arc stroke is two cubics. + let pen = prec(0x4008, 0x0200, &solid_pen(0xFF00_0000, 1.0)); + let arc = prec(0x4012, 0, &cat(vec![f32s(&[180.0, -180.0]), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![pen, arc]); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.len(), 3, "{segs:?}"); +} + +#[test] +fn emfplus_convex_clip_path_cuts_to_the_shape_not_its_box() { + // A diamond clip path (0,50) (50,0) (100,50) (50,100): the corner of a full square fill is cut away. + let diamond = cat(vec![h32(0xDBC0_1002), h32(4), h32(0), f32s(&[0.0, 50.0, 50.0, 0.0, 100.0, 50.0, 50.0, 100.0]), vec![0, 1, 1, 0x81]]); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0300 | 9, &diamond), prec(0x4033, 9, &[]), fill]); + let (segs, ..) = path_segs(&p.items[0]); + let pts: Vec<(f32, f32)> = segs.iter().filter_map(|s| if let Seg::Move(x, y) | Seg::Line(x, y) = s { Some((*x, *y)) } else { None }).collect(); + assert!(!pts.iter().any(|(x, y)| close(*x, 0.0) && close(*y, 0.0)), "corner kept: {pts:?}"); + assert!(pts.iter().all(|(x, y)| (x - 50.0).abs() + (y - 50.0).abs() <= 50.0 + 1e-3), "{pts:?}"); +} + +#[test] +fn emfplus_draw_string_wraps_to_the_layout_width() { + let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)); + let text = "alpha beta gamma delta"; + let s = prec( + 0x401C, + 0x8000, + &cat(vec![h32(0xFF00_0000), h32(9), h32(text.encode_utf16().count() as u32), f32s(&[0.0, 0.0, 60.0, 100.0]), u16s(text)]), + ); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 60.0, 100.0])])); + let p = plus_pic(vec![fill, font, s]); + let lines: Vec<&str> = p.items.iter().filter_map(|i| if let Item::Text(t) = i { Some(t.text.as_str()) } else { None }).collect(); + assert_eq!(lines, vec!["alpha beta", "gamma delta"]); +} + +#[test] +fn emfplus_nested_metafile_image_draws_its_items_into_the_destination() { + // The nested picture: a plain EMF with a 10 x 10 rectangle (white brush, black pen). + let inner = emf(&[erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))], [0; 4], [0; 4]); + let obj = cat(vec![h32(0xDBC0_1002), h32(2), h32(3), h32(inner.len() as u32), inner]); + let p = plus_pic(vec![prec(0x4008, 0x0500 | 3, &obj), draw_image(3, [0.0, 0.0, 40.0, 20.0])]); + assert_eq!(p.items.len(), 1); + assert_eq!((p.width, p.height), (40.0, 20.0)); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert!(segs.contains(&Seg::Line(40.0, 20.0)), "{segs:?}"); +} + +/// Curves and a region fill, as an EMF+ only file (also written to `$DECKCRAFT_EMF_OUT` to look at). +fn curves_file() -> Vec { + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF20_60C0, 3.0)); + // A closed curve through a diamond's corners, filled orange. + let diamond = f32s(&[100.0, 10.0, 190.0, 100.0, 100.0, 190.0, 10.0, 100.0]); + let fill = prec(0x4016, 0x8000, &cat(vec![h32(0xFFE0_8020), f32s(&[0.5]), h32(4), diamond.clone()])); + let outline = prec(0x4017, 1, &cat(vec![f32s(&[0.5]), h32(4), diamond])); + // An open curve (a smoothed line chart): all four segments of five points. + let line = + prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(0), h32(4), h32(5), f32s(&[210.0, 150.0, 260.0, 60.0, 310.0, 120.0, 360.0, 30.0, 410.0, 90.0])])); + // A green rectangle region, filled. + let region = cat(vec![h32(0xDBC0_1002), h32(0), h32(0x1000_0000), f32s(&[220.0, 160.0, 180.0, 30.0])]); + let fill_region = prec(0x4013, 0x8000 | 2, &h32(0xFF30_A040)); + // A cream background first. + let clear = prec(0x4009, 0, &h32(0xFFF4_EED8)); + let mut all = vec![plus_header(false), clear, pen, fill, outline, line, prec(0x4008, 0x0400 | 2, ®ion), fill_region]; + all.push(prec(0x4002, 0, &[])); + emf(&[plus_comment(&all)], [0, 0, 420, 200], [0; 4]) +} + +#[test] +fn emfplus_cardinal_curves_and_region_fill() { + let bytes = curves_file(); + if let Ok(path) = std::env::var("DECKCRAFT_EMF_OUT") { + let _ = std::fs::write(path, &bytes); + } + let p = parse(&bytes).unwrap(); + assert_eq!(p.items.len(), 5, "{:?}", p.items.len()); + assert_eq!(path_segs(&p.items[0]).1, Some([0xF4, 0xEE, 0xD8, 0xFF]), "the clear comes first"); + // The closed curve: four cubics through the diamond's corners, closed. + let (segs, fill, ..) = path_segs(&p.items[1]); + assert_eq!(fill, Some([0xE0, 0x80, 0x20, 0xFF])); + let cubics: Vec<(f32, f32)> = segs.iter().filter_map(|s| if let Seg::Cubic(.., x, y) = s { Some((*x, *y)) } else { None }).collect(); + assert_eq!(cubics.len(), 4, "{segs:?}"); + assert_eq!(segs.last(), Some(&Seg::Close)); + // The open curve: four cubics and no close; its controls bulge past the straight chords. + let (segs, fill, stroke, _) = path_segs(&p.items[3]); + assert_eq!((fill, stroke.map(|s| s.0)), (None, Some([0x20, 0x60, 0xC0, 0xFF]))); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Cubic(..))).count(), 4); + assert!(!segs.contains(&Seg::Close)); + // The region fill is its rectangle. + let (segs, fill, ..) = path_segs(&p.items[4]); + assert_eq!(fill, Some([0x30, 0xA0, 0x40, 0xFF])); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Line(..))).count(), 3, "{segs:?}"); +} + +#[test] +fn emfplus_curve_offsets_and_degenerate_input_are_safe() { + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF00_0000, 1.0)); + // Segments from point 2 of 5, asking for more than there are: two segments. + let line = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(2), h32(99), h32(5), f32s(&[0.0, 0.0, 10.0, 5.0, 20.0, 0.0, 30.0, 5.0, 40.0, 0.0])])); + let p = plus_pic(vec![pen.clone(), line]); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Cubic(..))).count(), 2, "{segs:?}"); + // One point, an offset past the end, a NaN tension, a missing region: nothing, no panic. + let one = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(0), h32(1), h32(1), f32s(&[1.0, 1.0])])); + let past = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(9), h32(9), h32(2), f32s(&[0.0, 0.0, 9.0, 9.0])])); + let nan = prec(0x4017, 1, &cat(vec![f32s(&[f32::NAN]), h32(3), f32s(&[0.0, 0.0, 9.0, 9.0, 0.0, 9.0])])); + let no_region = prec(0x4013, 0x8000 | 7, &h32(0xFF00_0000)); + let p = plus_pic(vec![pen, one, past, nan, no_region]); + assert!(p.items.iter().all(|i| path_segs(i).0.iter().filter(|s| matches!(s, Seg::Cubic(..))).count() == 0)); +} + +#[test] +fn emfplus_clear_paints_the_whole_frame_and_transparent_clears_nothing() { + let dot = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[40.0, 40.0, 10.0, 10.0])])); + let clear = |argb: u32| prec(0x4009, 0, &h32(argb)); + // 100 × 50 device units (0..2500 × 0..1250 hundredths of a millimetre at 0.04 units each). + let file = |recs: Vec>| { + let mut all = vec![plus_header(false)]; + all.extend(recs); + parse(&emf(&[plus_comment(&all)], [0, 0, 99, 49], [0, 0, 2500, 1250])).unwrap() + }; + // A translated, clipped world doesn't move or cut the clear. + let shift = prec(0x402D, 0, &f32s(&[30.0, 30.0])); + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 5.0, 5.0])); + let p = file(vec![shift, clip, clear(0xFFFF_FFFF), prec(0x4031, 0, &[]), dot.clone()]); + assert_eq!(p.items.len(), 2); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + let xs: Vec = segs.iter().filter_map(|s| if let Seg::Move(x, _) | Seg::Line(x, _) = s { Some(*x) } else { None }).collect(); + let span = xs.iter().cloned().fold(f32::MIN, f32::max) - xs.iter().cloned().fold(f32::MAX, f32::min); + assert!((span - p.width).abs() < 0.5, "clear spans {span} of {}", p.width); + // Transparent: only the dot. + assert_eq!(file(vec![clear(0x00FF_FFFF), dot.clone()]).items.len(), 1); + // No frame: nothing to clear. + assert_eq!(plus_pic(vec![clear(0xFFFF_FFFF), dot]).items.len(), 1); +} diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 93d391db..2d2491fe 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -34,10 +34,29 @@ 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, } impl ChartType { + /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus. + pub const OFFICE_2016: [ChartType; 7] = [ + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Pareto, + ChartType::BoxWhisker, + ChartType::Treemap, + ChartType::Sunburst, + ]; + + /// 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 == ChartType::RegionMap + } + pub const MAIN: [ChartType; 12] = [ ChartType::Column, ChartType::Bar, @@ -80,6 +99,7 @@ impl ChartType { ChartType::Funnel => "Funnel", ChartType::Treemap => "Treemap", ChartType::Sunburst => "Sunburst", + ChartType::RegionMap => "Map", ChartType::Combo => "Combo", } } @@ -243,6 +263,8 @@ pub struct Chart { pub title_size: Option, pub data_labels: bool, pub gridlines: bool, + /// `c:minorGridlines` on the value axis: five to a major unit, between the major ones. + pub minor_gridlines: bool, pub axis_titles: (Option, Option), /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). pub style: u32, @@ -258,10 +280,15 @@ pub struct Chart { pub y_max: Option, /// The value axis' major unit, when the chart fixes it. pub y_major: Option, + /// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000). + pub y_log_base: Option, /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). pub y_format: Option, /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. pub y_hidden: bool, + /// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a + /// horizontal bar chart top to bottom (the default puts the first category at the bottom). + pub cat_reversed: bool, /// A combo chart's secondary value axis: bounds, major unit and number format. pub y2_min: Option, pub y2_max: Option, @@ -270,6 +297,28 @@ pub struct Chart { pub y2_hidden: bool, /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. pub lang: Option, + /// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart + /// the running total, instead of floating. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub subtotals: Vec, + /// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents, + /// then theirs…), each with one label per category. A blank label continues the one above it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub category_levels: Vec>, + /// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub inclusive_quartiles: bool, + /// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between + /// the first and last series, hollow when the price rose. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub up_down_bars: bool, + /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are + /// automatic (Scott's rule, as Excel does). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_count: Option, + /// A histogram's bin width (`cx:binSize`). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_size: Option, /// Original chart XML part kept for round-trip of unmodelled options. #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, @@ -288,6 +337,7 @@ impl Default for Chart { title_size: None, data_labels: false, gridlines: true, + minor_gridlines: false, axis_titles: (None, None), style: 2, palette: "colorful1".into(), @@ -301,14 +351,22 @@ impl Default for Chart { y_min: None, y_max: None, y_major: None, + y_log_base: None, y_format: None, y_hidden: false, + cat_reversed: false, y2_min: None, y2_max: None, y2_major: None, y2_format: None, y2_hidden: false, lang: None, + subtotals: vec![], + category_levels: vec![], + inclusive_quartiles: false, + up_down_bars: false, + bin_count: None, + bin_size: None, raw: None, } } @@ -320,6 +378,45 @@ impl Chart { let cats = ["Q1", "Q2", "Q3", "Q4"]; let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() }; + if matches!(kind, ChartType::Treemap | ChartType::Sunburst) { + // A two-level hierarchy: months inside quarters. + c.categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(|s| s.to_string()).collect(); + c.category_levels = vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(|s| s.to_string()).collect()]; + let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0]; + c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + c.vary_colors = true; + return c; + } + if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + // These plot raw observations: a histogram bins one series, a box chart summarises + // each category's points. + let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0]; + let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 }; + c.categories = if kind == ChartType::BoxWhisker { + (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() + } else { + vec![] + }; + for s in 0..n_series { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(), + ..Default::default() + }); + } + c.gridlines = true; + c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string()); + return c; + } + if kind == ChartType::Stock { + // High-low-close over five days, the series in the order PowerPoint expects. + c.categories = ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(|s| s.to_string()).collect(); + let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])]; + for (name, v) in hlc { + c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + } + return c; + } let n_series = if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; for s in 0..n_series { @@ -331,6 +428,12 @@ impl Chart { }); } c.vary_colors = n_series == 1; + if kind == ChartType::Combo { + // Columns with the last series as a line, as PowerPoint's combo starts. + for (i, s) in c.series.iter_mut().enumerate() { + s.kind = Some(if i + 1 == n_series { ChartType::Line } else { ChartType::Column }); + } + } c } /// A series' own chart type: its own in a combo chart (columns when it names none), else the @@ -364,6 +467,9 @@ impl Chart { /// stacked series as running sums from zero, 100 % series as 0–1. `(0, 0)` without finite /// values; `None` when no series uses the axis. fn plotted_extent(&self, secondary: bool) -> Option<(f64, f64)> { + if self.kind == ChartType::Waterfall { + return (!secondary).then(|| self.value_range()); + } let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect(); if ser.is_empty() { return (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0)); @@ -391,6 +497,17 @@ impl Chart { lo = lo.min(m); } } + if !percent { + for s in &ser { + if self.series_kind(s).stacked() { + continue; + } + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } if percent { lo = lo.min(0.0); hi = hi.max(1.0); @@ -398,6 +515,28 @@ impl Chart { Some(if lo <= hi { (lo, hi) } else { (0.0, 0.0) }) } + /// A waterfall's bars as (from, to) on the value axis: each point floats from the running + /// total, a total point stands on zero and sets it. + pub fn waterfall_bars(&self) -> Vec<(f64, f64)> { + let Some(s) = self.series.first() else { return vec![] }; + let mut run = 0.0; + s.values + .iter() + .enumerate() + .map(|(i, v)| { + let v = v.unwrap_or(0.0); + if self.subtotals.contains(&i) { + run = v; + (0.0, v) + } else { + let from = run; + run += v; + (from, run) + } + }) + .collect() + } + /// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals: /// the bounds and unit the chart fixes, else Office's automatic ones ([`axis_scale`](fn@axis_scale)). /// `None` when no series uses the axis. @@ -408,6 +547,22 @@ impl Chart { Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent)) } + /// A logarithmic value axis' scale in exponents — (min, max, unit), so its labels are + /// `base^min`, `base^(min+unit)`… — covering the positive values: whole powers, as PowerPoint + /// and LibreOffice draw them. `None` when the axis isn't logarithmic or nothing is positive. + pub fn log_axis_scale(&self, max_intervals: usize) -> Option<(f64, f64, f64)> { + let b = self.y_log_base.filter(|b| b.is_finite() && *b >= 2.0)?; + let vals = self.series.iter().filter(|s| !self.on_secondary(s)).flat_map(|s| s.values.iter().flatten().copied()); + let (lo, hi) = vals + .filter(|v| v.is_finite() && *v > 0.0) + .fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, z)| (a.min(v), z.max(v)))))?; + let e = |v: f64| v.log(b); + let lo = self.y_min.filter(|v| *v > 0.0).map_or(e(lo).floor(), e); + let hi = self.y_max.filter(|v| *v > 0.0).map_or(e(hi).ceil(), e).max(lo + 1.0); + let unit = ((hi - lo) / max_intervals.max(1) as f64).ceil().max(1.0); + Some((lo, lo + ((hi - lo) / unit).ceil() * unit, unit)) + } + /// A value axis label in the axis' number format and the chart's language. pub fn axis_label(&self, secondary: bool, v: f64) -> String { let code = if secondary { &self.y2_format } else { &self.y_format }; @@ -430,6 +585,13 @@ impl Chart { pub fn value_range(&self) -> (f64, f64) { let mut lo: f64 = 0.0; let mut hi: f64 = 0.0; + if self.kind == ChartType::Waterfall { + for (a, b) in self.waterfall_bars() { + lo = lo.min(a.min(b)); + hi = hi.max(a.max(b)); + } + return (lo, hi); + } let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); if self.kind.stacked() { for i in 0..n { @@ -454,6 +616,14 @@ impl Chart { if self.kind.percent() { return (0.0, 1.0); } + if !self.kind.stacked() { + for s in &self.series { + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) } } @@ -551,6 +721,17 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { mod tests { use super::*; + #[test] + fn log_axes_run_over_whole_powers() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(1.0), Some(10.0), Some(1000.0), Some(50000.0)]; + assert_eq!(c.log_axis_scale(8), None); + c.y_log_base = Some(10.0); + assert_eq!(c.log_axis_scale(8), Some((0.0, 5.0, 1.0))); + assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); + } + #[test] fn nice_scale_covers() { let (a, b, s) = nice_scale(0.0, 5.1); @@ -695,6 +876,17 @@ mod tests { assert_eq!(ticks, ["0", "0.025", "0.05", "0.075", "0.1", "0.125"]); } + #[test] + fn a_forecast_grows_the_value_axis() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)]; + let before = c.value_range().1; + c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() }); + let after = c.value_range().1; + assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}"); + } + #[test] fn axis_labels_use_the_format_and_language() { let mut c = Chart::sample(ChartType::Column); @@ -753,3 +945,376 @@ mod tests { assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); } } + +/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the +/// left, as Excel bins) and how many observations fall in it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bin { + pub lo: f64, + pub hi: f64, + pub count: usize, +} + +/// Bins a histogram has at most (a hostile bin size cannot make millions). +pub const MAX_BINS: usize = 1000; + +/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at +/// the furthest points within 1.5 IQR of the box, and the points beyond them. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxStats { + pub low: f64, + pub q1: f64, + pub median: f64, + pub q3: f64, + pub high: f64, + pub mean: f64, + pub outliers: Vec, +} + +impl Chart { + /// The first series' finite observations. + fn observations(&self) -> Vec { + self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default() + } + + /// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else + /// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations. + pub fn histogram_bins(&self) -> Vec { + let mut v = self.observations(); + if v.is_empty() { + return vec![]; + } + v.sort_by(f64::total_cmp); + let (lo, hi) = (v[0], v[v.len() - 1]); + let span = hi - lo; + let n = v.len() as f64; + let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) { + w + } else if let Some(k) = self.bin_count.filter(|k| *k > 0) { + span / f64::from(k) + } else { + let mean = v.iter().sum::() / n; + let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0).max(1.0)).sqrt(); + 3.49 * sd / n.cbrt() + }; + let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { 1 } else { ((span / width).ceil() as usize).clamp(1, MAX_BINS) }; + let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) }; + let mut bins: Vec = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect(); + for x in v { + // (lo, hi] bins, the first one also holding the minimum. + let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1); + if let Some(b) = bins.get_mut(i) { + b.count += 1; + } + } + bins + } + + /// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with + /// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the + /// cumulative percentage as a line on a 0–100 % secondary axis. + pub fn histogram_view(&self) -> Chart { + let mut bins = self.histogram_bins(); + let pareto = self.kind == ChartType::Pareto; + if pareto { + bins.sort_by_key(|b| std::cmp::Reverse(b.count)); + } + let fmt = |v: f64| { + let r = (v * 100.0).round() / 100.0; + if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } + }; + let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min); + let mut v = Chart { + kind: if pareto { ChartType::Combo } else { ChartType::Column }, + categories: bins + .iter() + .map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) }) + .collect(), + series: vec![], + gap_width: 0.0, + raw: None, + subtotals: vec![], + ..self.clone() + }; + let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default(); + let base = self.series.first().cloned().unwrap_or_default(); + v.series.push(Series { + name, + values: bins.iter().map(|b| Some(b.count as f64)).collect(), + kind: pareto.then_some(ChartType::Column), + number_format: None, + ..base + }); + if pareto { + let total = bins.iter().map(|b| b.count).sum::().max(1) as f64; + let mut run = 0.0; + v.series.push(Series { + name: "Cumulative %".into(), + values: bins + .iter() + .map(|b| { + run += b.count as f64; + Some(run / total) + }) + .collect(), + kind: Some(ChartType::Line), + secondary_axis: true, + ..Default::default() + }); + v.y2_min = Some(0.0); + v.y2_max = Some(1.0); + v.y2_major = Some(0.2); + v.y2_format = Some("0%".into()); + } + v.vary_colors = false; + v + } + + /// The box chart's groups (distinct category labels in order of appearance; one unnamed + /// group without categories) and, per series, each group's summary (None when it has no + /// points). + pub fn box_groups(&self) -> (Vec, Vec>>) { + let mut groups: Vec = Vec::new(); + for c in &self.categories { + if !groups.contains(c) { + groups.push(c.clone()); + } + } + if groups.is_empty() { + groups.push(String::new()); + } + let stats = self + .series + .iter() + .map(|s| { + groups + .iter() + .map(|g| { + let pts: Vec = s + .values + .iter() + .enumerate() + .filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) + .filter_map(|(_, v)| *v) + .filter(|v| v.is_finite()) + .collect(); + box_stats_by(pts, self.inclusive_quartiles) + }) + .collect() + }) + .collect(); + (groups, stats) + } +} + +/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends. +fn quantile_exc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64); + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = v[i.saturating_sub(1).min(n - 1)]; + let b = v[i.min(n - 1)]; + a + (b - a) * frac +} + +/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data. +fn quantile_inc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64); + let i = pos.floor() as usize; + let a = v[i.min(n - 1)]; + let b = v[(i + 1).min(n - 1)]; + a + (b - a) * (pos - i as f64) +} + +/// The summary of one group's points, with exclusive quartiles. +pub fn box_stats(pts: Vec) -> Option { + box_stats_by(pts, false) +} + +/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC. +pub fn box_stats_by(mut pts: Vec, inclusive: bool) -> Option { + if pts.is_empty() { + return None; + } + pts.sort_by(f64::total_cmp); + let q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) }; + let (q1, median, q3) = (q(0.25), q(0.5), q(0.75)); + let iqr = q3 - q1; + let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); + let low = inside.first().copied().unwrap_or(q1).min(q1); + let high = inside.last().copied().unwrap_or(q3).max(q3); + let mean = pts.iter().sum::() / pts.len() as f64; + let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect(); + Some(BoxStats { low, q1, median, q3, high, mean, outliers }) +} + +/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and +/// its children (none for a leaf, which names its category index). +#[derive(Clone, Debug, PartialEq)] +pub struct TreeNode { + pub name: String, + pub value: f64, + pub leaf: Option, + pub children: Vec, +} + +/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper). +pub const MAX_LEVELS: usize = 16; + +impl Chart { + /// The first series as a hierarchy: top-level nodes, each holding the consecutive + /// categories that share its label, down to the leaves. Without outer levels every + /// category is a top-level leaf. Negative and missing values count as zero. + pub fn category_tree(&self) -> Vec { + let Some(s) = self.series.first() else { return vec![] }; + let n = self.categories.len().max(s.values.len()); + let levels: Vec<&Vec> = self.category_levels.iter().take(MAX_LEVELS).collect(); + // Each leaf's path from the top level down, blanks filled from the row above. + let mut last: Vec = vec![String::new(); levels.len()]; + let paths: Vec> = (0..n) + .map(|i| { + let mut path = Vec::with_capacity(levels.len() + 1); + for (d, lv) in levels.iter().enumerate().rev() { + let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); + if !l.is_empty() { + last[d] = l.to_string(); + // A new outer label starts fresh inner groups. + for inner in last.iter_mut().take(d) { + inner.clear(); + } + } + path.push(last[d].clone()); + } + path.push(self.categories.get(i).cloned().unwrap_or_default()); + path + }) + .collect(); + let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0); + fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec { + let mut out: Vec = Vec::new(); + let mut i = 0; + while i < rows.len() { + let (idx, path) = rows[i]; + if path.len() <= 1 { + out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] }); + i += 1; + continue; + } + let head = &path[0]; + let mut j = i; + while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head { + j += 1; + } + let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); + let children = build(&sub, value); + out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); + i = j; + } + out + } + let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); + build(&rows, &value) + } +} + +#[cfg(test)] +mod chartex_tests { + use super::*; + + fn obs(v: &[f64]) -> Chart { + Chart { + kind: ChartType::Histogram, + series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }], + ..Default::default() + } + } + + #[test] + fn histogram_bins_by_count_size_and_scott() { + let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]); + c.bin_count = Some(4); + let b = c.histogram_bins(); + assert_eq!(b.len(), 4); + assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0)); + // (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9. + assert_eq!(b.iter().map(|b| b.count).collect::>(), vec![4, 2, 0, 1]); + c.bin_count = None; + c.bin_size = Some(5.0); + assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::>(), vec![6, 1]); + c.bin_size = None; + let auto = c.histogram_bins(); + assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::() == 7); + // A tiny bin size is capped; one value gives one bin. + c.bin_size = Some(1e-12); + assert!(c.histogram_bins().len() <= MAX_BINS); + assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]); + assert!(obs(&[]).histogram_bins().is_empty()); + } + + #[test] + fn pareto_view_sorts_bins_and_adds_the_cumulative_line() { + let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]); + c.kind = ChartType::Pareto; + c.bin_count = Some(3); + let v = c.histogram_view(); + assert_eq!(v.kind, ChartType::Combo); + assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]); + let cum = &v.series[1]; + assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line)); + assert_eq!(cum.values.last(), Some(&Some(1.0))); + assert_eq!(v.categories[0], "(3.67, 6.33]"); + } + + #[test] + fn box_stats_use_exclusive_quartiles_and_fence_outliers() { + let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap(); + // QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75. + assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75)); + assert_eq!(b.outliers, vec![100.0]); + assert_eq!((b.low, b.high), (1.0, 7.0)); + assert!(box_stats(vec![]).is_none()); + let one = box_stats(vec![5.0]).unwrap(); + assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); + } + + #[test] + fn inclusive_quartiles_follow_quartile_inc() { + let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap(); + // QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based). + assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25)); + } + + #[test] + fn category_tree_groups_runs_and_fills_blank_parents() { + let mut c = Chart::sample(ChartType::Treemap); + let t = c.category_tree(); + assert_eq!(t.iter().map(|n| n.name.as_str()).collect::>(), ["Q1", "Q2", "Q3"]); + assert_eq!(t[0].children.len(), 3); + assert_eq!(t[0].children[1].leaf, Some(1)); + assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9); + // Blank parents continue the label above; two outer levels nest. + c.category_levels = vec![ + vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()], + vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()], + ]; + let t = c.category_tree(); + assert_eq!(t.len(), 2); + assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::>(), ["a", "b"]); + assert_eq!(t[1].children[0].children.len(), 4); + // No levels: every category is a leaf. + c.category_levels.clear(); + assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); + } + + #[test] + fn box_groups_follow_category_labels() { + let c = Chart::sample(ChartType::BoxWhisker); + let (groups, stats) = c.box_groups(); + assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]); + assert_eq!(stats.len(), 2); + assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 5151d777..82c9100e 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -17,6 +17,7 @@ pub mod style; pub mod table; pub mod text; pub mod theme; +mod trendline; pub mod words; use std::sync::Arc; diff --git a/crates/model/src/trendline.rs b/crates/model/src/trendline.rs new file mode 100644 index 00000000..f893a764 --- /dev/null +++ b/crates/model/src/trendline.rs @@ -0,0 +1,623 @@ +//! Least-squares trendlines: linear, exponential, logarithmic, polynomial, power, and a +//! moving average. The fit is what the chart drawer plots and what the axis grows to include. + +use crate::chart::{ChartType, Series, Trendline, TrendlineType}; + +/// A fitted trendline. `y` is `None` where the model is undefined (a log of a non-positive x, +/// an exponential that overflowed). +#[derive(Clone, Debug)] +pub struct TrendFit { + kind: TrendlineType, + model: Model, + r2: f64, + x_min: f64, + x_max: f64, +} + +#[derive(Clone, Debug)] +enum Model { + /// `y = c[0] + c[1] x + c[2] x² + …` + Poly(Vec), + /// `y = c · e^(b x)` + Exp { c: f64, b: f64 }, + /// `y = b + c · ln(x)` + Log { b: f64, c: f64 }, + /// `y = c · x^b` + Power { c: f64, b: f64 }, + /// Window averages in x order. No equation. + Moving(Vec<(f64, f64)>), +} + +impl Trendline { + /// Fit `xs`/`ys` (paired). Non-finite pairs are dropped. `None` when the type needs more + /// points than it was given, or the normal equations have no solution. + pub fn fit(&self, xs: &[f64], ys: &[f64]) -> Option { + let mut pairs: Vec<(f64, f64)> = + xs.iter().zip(ys).map(|(x, y)| (*x, *y)).filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect(); + if pairs.len() < 2 { + return None; + } + pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let (x_min, x_max) = (pairs.first().map(|p| p.0)?, pairs.last().map(|p| p.0)?); + let intercept = self.intercept.filter(|v| v.is_finite()); + let (model, used_y, yhat) = match self.kind { + TrendlineType::Linear => { + let (xs, ys) = split(&pairs); + let (b, m) = linreg(&xs, &ys, intercept)?; + let yhat: Vec = xs.iter().map(|x| b + m * x).collect(); + (Model::Poly(vec![b, m]), ys, yhat) + } + TrendlineType::Poly => { + let order = self.order.unwrap_or(2).clamp(2, 6) as usize; + if pairs.len() < order + 1 { + return None; + } + let (xs, ys) = split(&pairs); + let coeff = polyfit(&xs, &ys, order, intercept)?; + let yhat: Vec = xs.iter().map(|x| poly_y(&coeff, *x)).collect(); + (Model::Poly(coeff), ys, yhat) + } + TrendlineType::Exp => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(_, y)| *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (xs, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln()); + let (ln_c, b) = linreg(&xs, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = xs.iter().map(|x| c * (b * x).exp()).collect(); + (Model::Exp { c, b }, ys, yhat) + } + TrendlineType::Log => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, _)| *x > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (ln_x, ys): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), *y)).unzip(); + let (b, c) = linreg(&ln_x, &ys, intercept)?; + let yhat: Vec = ln_x.iter().map(|u| b + c * u).collect(); + (Model::Log { b, c }, ys, yhat) + } + TrendlineType::Power => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (ln_x, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln()); + let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = pairs.iter().map(|(x, _)| c * x.powf(b)).collect(); + (Model::Power { c, b }, ys, yhat) + } + TrendlineType::MovingAvg => { + let period = self.period.unwrap_or(2).clamp(2, 255) as usize; + if pairs.len() <= period { + return None; + } + let mut pts = Vec::new(); + let mut ys = Vec::new(); + let mut yhat = Vec::new(); + for i in period - 1..pairs.len() { + let window = &pairs[i + 1 - period..=i]; + let avg = window.iter().map(|(_, y)| y).sum::() / period as f64; + if avg.is_finite() { + pts.push((pairs[i].0, avg)); + ys.push(pairs[i].1); + yhat.push(avg); + } + } + if pts.len() < 2 { + return None; + } + (Model::Moving(pts), ys, yhat) + } + }; + if yhat.iter().any(|v| !v.is_finite()) { + return None; + } + // A moving average starts at the first full window, not at the first data point. + let (x_min, x_max) = if let Model::Moving(pts) = &model { + match (pts.first(), pts.last()) { + (Some(a), Some(b)) => (a.0, b.0), + _ => (x_min, x_max), + } + } else { + (x_min, x_max) + }; + Some(TrendFit { kind: self.kind, model, r2: r_squared(&used_y, &yhat), x_min, x_max }) + } + + /// The equation and R² lines this trendline asks to show. Empty when neither is on. A moving + /// average has no equation, and PowerPoint does not show its R², so that label is left off + /// even when the file asks for it. + pub fn caption(&self, fit: &TrendFit) -> String { + let mut lines = Vec::new(); + if self.disp_eq + && let Some(eq) = fit.equation() + { + lines.push(eq); + } + if self.disp_r_sqr && self.kind != TrendlineType::MovingAvg { + lines.push(fit.r2_label()); + } + lines.join("\n") + } +} + +impl TrendFit { + pub fn y(&self, x: f64) -> Option { + if !x.is_finite() { + return None; + } + let y = match &self.model { + Model::Poly(c) => poly_y(c, x), + Model::Exp { c, b } => c * (b * x).exp(), + Model::Log { b, c } => { + if x <= 0.0 { + return None; + } + b + c * x.ln() + } + Model::Power { c, b } => { + if x <= 0.0 { + return None; + } + c * x.powf(*b) + } + Model::Moving(_) => return None, + }; + y.is_finite().then_some(y) + } + + /// Samples from `x0` to `x1`, forecast included. A moving average returns its window points + /// and ignores the forecast (Excel does not extend one). + pub fn samples(&self, x0: f64, x1: f64) -> Vec<(f64, f64)> { + let (x0, x1) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; + if !x0.is_finite() || !x1.is_finite() { + return Vec::new(); + } + if let Model::Moving(pts) = &self.model { + return pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect(); + } + if matches!(self.kind, TrendlineType::Linear) { + return [x0, x1].into_iter().filter_map(|x| self.y(x).map(|y| (x, y))).collect(); + } + let steps = 48; + (0..=steps) + .filter_map(|i| { + let x = x0 + (x1 - x0) * i as f64 / steps as f64; + self.y(x).map(|y| (x, y)) + }) + .collect() + } + + /// Data x plus forecast, in category (or scatter x) units. Forecast is capped so a hostile + /// value cannot walk off the chart. + pub fn span(&self, forward: Option, backward: Option) -> (f64, f64) { + let pad = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(24.0)).unwrap_or(0.0); + if matches!(self.kind, TrendlineType::MovingAvg) { (self.x_min, self.x_max) } else { (self.x_min - pad(backward), self.x_max + pad(forward)) } + } + + pub fn equation(&self) -> Option { + let body = match &self.model { + Model::Poly(c) => poly_equation(c), + Model::Exp { c, b } => Some(format!("y = {}e^({}x)", num(*c), num(*b))), + Model::Log { b, c } => poly_equation(&[*b, *c]).map(|s| s.replacen("x", "ln(x)", 1)), + Model::Power { c, b } => { + let mag = if (c.abs() - 1.0).abs() < 1e-6 { String::new() } else { num(c.abs()) }; + let sign = if *c < 0.0 { "-" } else { "" }; + Some(format!("y = {sign}{mag}{}", x_pow(*b))) + } + Model::Moving(_) => None, + }?; + Some(body) + } + + pub fn r2_label(&self) -> String { + let r = if self.r2.is_finite() { self.r2 } else { 0.0 }; + if (r - 1.0).abs() < 5e-4 { "R² = 1".to_string() } else { format!("R² = {r:.3}") } + } +} + +impl Series { + /// Trendline y values, including a short forecast, that sit near this series. The axis uses + /// them so a forecast is not clipped off the plot. A value many times the series' own range + /// is left out, so a wild polynomial cannot flatten the chart. + pub fn trendline_ys(&self, scatter: bool) -> Vec { + let n = self.values.len().min(10_000); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for i in 0..n { + let Some(y) = self.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + let x = if scatter { + let Some(x) = self.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + x + } else { + (i + 1) as f64 + }; + xs.push(x); + ys.push(y); + } + let (data_lo, data_hi) = ys.iter().fold((f64::MAX, f64::MIN), |(lo, hi), y| (lo.min(*y), hi.max(*y))); + if !data_lo.is_finite() || !data_hi.is_finite() { + return Vec::new(); + } + let span = (data_hi - data_lo).abs().max(1.0); + let mid = (data_lo + data_hi) / 2.0; + let mut out = Vec::new(); + for t in self.trendlines.iter().take(8) { + let Some(fit) = t.fit(&xs, &ys) else { continue }; + let (x0, x1) = fit.span(t.forward, t.backward); + for (_, y) in fit.samples(x0, x1) { + if y.is_finite() && (y - mid).abs() <= span * 8.0 { + out.push(y); + } + } + } + out + } +} + +/// Whether a series is plotted against numeric x values rather than category positions. +pub fn series_is_scatter(kind: ChartType) -> bool { + matches!(kind, ChartType::Scatter | ChartType::Bubble) +} + +fn split(pairs: &[(f64, f64)]) -> (Vec, Vec) { + pairs.iter().map(|(x, y)| (*x, *y)).unzip() +} + +/// `y = b + m x`. `intercept` fixes `b`. +fn linreg(xs: &[f64], ys: &[f64], intercept: Option) -> Option<(f64, f64)> { + if xs.len() != ys.len() || xs.len() < 2 { + return None; + } + if let Some(b) = intercept { + let mut sxx = 0.0; + let mut sxy = 0.0; + for (x, y) in xs.iter().zip(ys) { + sxx += x * x; + sxy += x * (y - b); + } + if sxx.abs() < 1e-18 { + return None; + } + let m = sxy / sxx; + return (m.is_finite() && b.is_finite()).then_some((b, m)); + } + let n = xs.len() as f64; + let (mut sx, mut sy, mut sxx, mut sxy) = (0.0, 0.0, 0.0, 0.0); + for (x, y) in xs.iter().zip(ys) { + sx += x; + sy += y; + sxx += x * x; + sxy += x * y; + } + let den = n * sxx - sx * sx; + if den.abs() < 1e-18 { + return None; + } + let m = (n * sxy - sx * sy) / den; + let b = (sy - m * sx) / n; + (m.is_finite() && b.is_finite()).then_some((b, m)) +} + +fn poly_y(coeff: &[f64], x: f64) -> f64 { + let mut acc = 0.0; + let mut xp = 1.0; + for c in coeff { + acc += c * xp; + if !acc.is_finite() { + return f64::NAN; + } + xp *= x; + } + acc +} + +/// Polynomial through the normal equations. `x` is centred when the intercept is free, then +/// expanded back to powers of `x`, so order 6 on category numbers stays solvable. +#[allow(clippy::needless_range_loop)] // the index is the power in the normal equations +fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option) -> Option> { + let n = xs.len(); + if n != ys.len() || !(1..=6).contains(&order) || n < order + 1 { + return None; + } + if let Some(b0) = intercept { + let m = order; + let mut ata = vec![vec![0.0; m]; m]; + let mut atb = vec![0.0; m]; + for (x, y) in xs.iter().zip(ys) { + let dy = y - b0; + for i in 0..m { + let xi = x.powi((i + 1) as i32); + atb[i] += dy * xi; + for j in i..m { + ata[i][j] += xi * x.powi((j + 1) as i32); + } + } + } + for i in 0..m { + for j in 0..i { + ata[i][j] = ata[j][i]; + } + } + let solved = solve(ata, atb)?; + let mut coeff = Vec::with_capacity(order + 1); + coeff.push(b0); + coeff.extend(solved); + return coeff.iter().all(|v| v.is_finite()).then_some(coeff); + } + let xm = xs.iter().sum::() / n as f64; + let us: Vec = xs.iter().map(|x| x - xm).collect(); + let m = order + 1; + let mut ata = vec![vec![0.0; m]; m]; + let mut atb = vec![0.0; m]; + for (u, y) in us.iter().zip(ys) { + for i in 0..m { + let ui = if i == 0 { 1.0 } else { u.powi(i as i32) }; + atb[i] += y * ui; + for j in i..m { + let uj = if j == 0 { 1.0 } else { u.powi(j as i32) }; + ata[i][j] += ui * uj; + } + } + } + for i in 0..m { + for j in 0..i { + ata[i][j] = ata[j][i]; + } + } + let centred = solve(ata, atb)?; + let coeff = expand_shift(¢red, xm); + coeff.iter().all(|v| v.is_finite()).then_some(coeff) +} + +/// `a[k]` is the coefficient of `(x - xm)^k`. Return coefficients of powers of `x`. +#[allow(clippy::needless_range_loop)] // j is the power of x after the binomial shift +fn expand_shift(a: &[f64], xm: f64) -> Vec { + let mut c = vec![0.0; a.len()]; + for (k, ak) in a.iter().enumerate() { + let mut binom = 1.0; + for j in 0..=k { + c[j] += ak * binom * (-xm).powi((k - j) as i32); + if j < k { + binom *= (k - j) as f64 / (j + 1) as f64; + } + } + } + c +} + +#[allow(clippy::needless_range_loop)] // Gaussian elimination walks rows and columns by index +fn solve(mut a: Vec>, mut b: Vec) -> Option> { + let n = b.len(); + if n == 0 || n > 8 || a.len() != n { + return None; + } + for col in 0..n { + let mut piv = col; + for r in col + 1..n { + if a[r][col].abs() > a[piv][col].abs() { + piv = r; + } + } + if a[piv][col].abs() < 1e-12 { + return None; + } + a.swap(col, piv); + b.swap(col, piv); + let div = a[col][col]; + for c in col..n { + a[col][c] /= div; + } + b[col] /= div; + for r in 0..n { + if r == col { + continue; + } + let f = a[r][col]; + if f == 0.0 { + continue; + } + for c in col..n { + a[r][c] -= f * a[col][c]; + } + b[r] -= f * b[col]; + } + } + b.iter().all(|v| v.is_finite()).then_some(b) +} + +fn r_squared(ys: &[f64], yhat: &[f64]) -> f64 { + if ys.is_empty() || ys.len() != yhat.len() { + return 0.0; + } + let mean = ys.iter().sum::() / ys.len() as f64; + let mut ss_tot = 0.0; + let mut ss_res = 0.0; + for (y, h) in ys.iter().zip(yhat) { + ss_tot += (y - mean) * (y - mean); + ss_res += (y - h) * (y - h); + } + if ss_tot < 1e-18 { + return if ss_res < 1e-12 { 1.0 } else { 0.0 }; + } + let r = 1.0 - ss_res / ss_tot; + if r.is_finite() { r } else { 0.0 } +} + +fn num(v: f64) -> String { + if !v.is_finite() { + return "0".to_string(); + } + let a = v.abs(); + if a == 0.0 { + return "0".to_string(); + } + if a >= 1.0e6 || a < 1.0e-3 { + return format!("{v:.2e}").replace('+', ""); + } + let digits = if a >= 100.0 { + 1 + } else if a >= 10.0 { + 2 + } else { + 3 + }; + let mut s = format!("{v:.prec$}", prec = digits); + if let Some(dot) = s.find('.') { + while s.ends_with('0') { + s.pop(); + } + if s.len() == dot + 1 { + s.pop(); + } + } + s +} + +fn x_pow(p: f64) -> String { + if (p - 1.0).abs() < 1e-6 { + "x".to_string() + } else if (p - 2.0).abs() < 1e-6 { + "x²".to_string() + } else if (p - 3.0).abs() < 1e-6 { + "x³".to_string() + } else { + format!("x^{}", num(p)) + } +} + +fn poly_equation(coeff: &[f64]) -> Option { + let scale = coeff.iter().map(|c| c.abs()).fold(0.0, f64::max).max(1.0); + let mut body = String::new(); + let mut first = true; + for (k, c) in coeff.iter().enumerate().rev() { + if c.abs() < scale * 1e-8 { + continue; + } + let pow = match k { + 0 => String::new(), + 1 => "x".to_string(), + _ => x_pow(k as f64), + }; + let mag = if pow.is_empty() { + num(c.abs()) + } else if (c.abs() - 1.0).abs() < scale * 1e-8 { + pow + } else { + format!("{}{}", num(c.abs()), pow) + }; + if first { + if *c < 0.0 { + body.push('-'); + } + body.push_str(&mag); + first = false; + } else if *c < 0.0 { + body.push_str(" - "); + body.push_str(&mag); + } else { + body.push_str(" + "); + body.push_str(&mag); + } + } + if body.is_empty() { + body.push('0'); + } + Some(format!("y = {body}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(kind: TrendlineType) -> Trendline { + Trendline { kind, ..Default::default() } + } + + #[test] + fn linear_recovers_slope_and_a_fixed_intercept() { + let xs = [1.0, 2.0, 3.0, 4.0]; + let ys = [3.0, 5.0, 7.0, 9.0]; + let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit"); + assert!((fit.y(1.0).unwrap() - 3.0).abs() < 1e-9); + assert!((fit.y(4.0).unwrap() - 9.0).abs() < 1e-9); + assert_eq!(fit.equation().as_deref(), Some("y = 2x + 1")); + assert_eq!(fit.r2_label(), "R² = 1"); + let mut forced = line(TrendlineType::Linear); + forced.intercept = Some(0.0); + let fit = forced.fit(&[1.0, 2.0], &[2.0, 4.0]).expect("fit"); + assert!((fit.y(3.0).unwrap() - 6.0).abs() < 1e-6); + assert_eq!(fit.equation().as_deref(), Some("y = 2x")); + } + + #[test] + fn exp_log_power_and_quadratic_round_their_points() { + let xs = [1.0, 2.0, 3.0, 4.0]; + let exp = line(TrendlineType::Exp).fit(&xs, &[1.0, 2.0, 4.0, 8.0]).expect("exp"); + for (x, y) in xs.iter().zip([1.0, 2.0, 4.0, 8.0]) { + assert!((exp.y(*x).unwrap() - y).abs() < 1e-6, "{x}"); + } + assert!(exp.equation().unwrap().contains("e^(")); + let log = line(TrendlineType::Log).fit(&xs, &[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()]).expect("log"); + assert!((log.y(2.0).unwrap() - 2.0_f64.ln()).abs() < 1e-6); + assert!(log.equation().unwrap().contains("ln(x)")); + let power = line(TrendlineType::Power).fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("power"); + assert!((power.y(3.0).unwrap() - 9.0).abs() < 1e-6); + assert_eq!(power.equation().as_deref(), Some("y = x²")); + let mut poly = line(TrendlineType::Poly); + poly.order = Some(2); + let quad = poly.fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("poly"); + assert!((quad.y(3.0).unwrap() - 9.0).abs() < 1e-6); + assert_eq!(quad.equation().as_deref(), Some("y = x²")); + } + + #[test] + fn moving_average_and_forecast_span() { + let fit = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), ..Default::default() } + .fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0]) + .expect("ma"); + let pts = fit.samples(0.0, 10.0); + assert_eq!(pts.len(), 3); + assert!((pts[0].1 - 5.0).abs() < 1e-9); + assert!((pts[1].1 - 6.0).abs() < 1e-9); + assert!((pts[2].1 - 7.0).abs() < 1e-9); + assert!(fit.equation().is_none()); + let linear = line(TrendlineType::Linear).fit(&[1.0, 2.0], &[1.0, 2.0]).expect("line"); + assert_eq!(linear.span(Some(1.5), Some(-3.0)), (1.0, 3.5)); + assert_eq!(fit.span(Some(4.0), Some(4.0)), (2.0, 4.0)); + } + + #[test] + fn moving_average_shows_neither_equation_nor_r2() { + let t = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), disp_eq: true, disp_r_sqr: true, ..Default::default() }; + let fit = t.fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0]).expect("ma"); + assert!(fit.r2_label().starts_with("R² =")); + assert!(t.caption(&fit).is_empty(), "{}", t.caption(&fit)); + } + + #[test] + fn too_few_points_and_non_finite_input_fit_nothing() { + assert!(line(TrendlineType::Linear).fit(&[1.0], &[2.0]).is_none()); + assert!(line(TrendlineType::Exp).fit(&[1.0, 2.0], &[-1.0, -2.0]).is_none()); + assert!(line(TrendlineType::Poly).fit(&[1.0, 2.0], &[1.0, 2.0]).is_none()); + assert!(line(TrendlineType::Linear).fit(&[f64::NAN, f64::INFINITY], &[1.0, 2.0]).is_none()); + let mut t = line(TrendlineType::Linear); + t.disp_eq = true; + t.disp_r_sqr = true; + let fit = t.fit(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]).expect("fit"); + assert_eq!(t.caption(&fit), "y = x\nR² = 1"); + } +} diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index f4e70368..7be64997 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -26,7 +26,7 @@ fn simple_line(sp: Option<&El>) -> Option { Some(l) } -fn rich_text(t: &El) -> Option { +pub(crate) fn rich_text(t: &El) -> Option { let tx = t.child("tx")?; if let Some(r) = tx.child("rich") { let s = r.children_named("p").map(|p| p.text()).collect::>().join("\n"); @@ -188,18 +188,28 @@ fn chart_within(root: &El, mut budget: usize) -> Option { ChartType::Radar } } - "stockChart" => ChartType::Stock, + "stockChart" => { + c.up_down_bars = g.child("upDownBars").is_some(); + ChartType::Stock + } "surfaceChart" | "surface3DChart" => ChartType::Surface, _ => continue, }; if let Some(v) = g.child_val("varyColors") { c.vary_colors = v == "1" || v == "true"; } + // A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %), + // not the 219 % / -27 % PowerPoint writes into its new charts. + let bar_group = g.is("barChart") || g.is("bar3DChart"); if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { c.gap_width = (v / 100.0).clamp(0.0, 5.0); + } else if bar_group { + c.gap_width = 1.5; } if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::().ok()) { c.overlap = (v / 100.0).clamp(-1.0, 1.0); + } else if bar_group { + c.overlap = 0.0; } if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::().ok()) { c.hole_size = (v / 100.0).clamp(0.0, 0.9); @@ -308,21 +318,26 @@ fn chart_within(root: &El, mut budget: usize) -> Option { let cat_ax = pa.child("catAx").or_else(|| pa.child("dateAx")).or_else(|| pa.children_named("valAx").find(|a| !vertical(a))); if let Some(v) = val_ax { c.gridlines = v.child("majorGridlines").is_some(); + c.minor_gridlines = v.child("minorGridlines").is_some(); let sc = v.child("scaling"); c.y_min = sc.and_then(|s| s.child_val("min")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); c.axis_titles.1 = v.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); c.y_major = num(v, "majorUnit"); + c.y_log_base = sc.and_then(|s| s.child_val("logBase")).and_then(|v| v.parse().ok()).filter(|b: &f64| b.is_finite() && *b >= 2.0); c.y_format = format_code(v); c.y_hidden = deleted(v); } if let Some(a) = cat_ax { + c.cat_reversed = a.child("scaling").and_then(|s| s.child_val("orientation")).is_some_and(|o| o == "maxMin"); c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); } // Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to // the chart): the axes' and legend's, else the whole chart's; the title's own. let chart_size = text_size(root.child("txPr")); c.text_size = [val_ax, cat_ax, ch.child("legend")].into_iter().flatten().find_map(|e| text_size(e.child("txPr"))).or(chart_size); + // Text colour likewise: the axes' and legend's, else the whole chart's. + c.text_color = [val_ax, cat_ax, ch.child("legend"), Some(root)].into_iter().flatten().find_map(|e| text_color(e.child("txPr"))); c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr")))); Some(c) } @@ -354,6 +369,27 @@ fn trendline(e: &El) -> Option { }) } +/// The colour of the first `a:defRPr` or `a:rPr` with a solid fill under `e`. +fn text_color(e: Option<&El>) -> Option { + fn rec(e: &El, depth: usize) -> Option { + if depth > 8 { + return None; + } + for c in e.elements() { + if (c.is("defRPr") || c.is("rPr")) + && let Some(Fill::Solid { color }) = simple_fill(Some(c)) + { + return Some(color); + } + if let Some(v) = rec(c, depth + 1) { + return Some(v); + } + } + None + } + rec(e?, 0) +} + /// The size in points of the first `a:defRPr` or `a:rPr` with one under `e` (`sz` in hundredths). fn text_size(e: Option<&El>) -> Option { fn rec(e: &El, depth: usize) -> Option { @@ -405,6 +441,22 @@ mod tests { assert!(c.series.iter().all(|s| s.values.len() == 100_000)); } + #[test] + fn minor_gridlines_are_read_and_written_back() { + let mut c = Chart::sample(ChartType::Line); + c.minor_gridlines = true; + let back = read_chart_xml(&crate::write::chart::chart_xml(&c)).unwrap(); + assert!(back.gridlines && back.minor_gridlines); + c.minor_gridlines = false; + assert!(!read_chart_xml(&crate::write::chart::chart_xml(&c)).unwrap().minor_gridlines); + } + + #[test] + fn log_base_is_read() { + let xml = br#""#; + assert_eq!(read_chart_xml(xml).unwrap().y_log_base, Some(10.0)); + } + #[test] fn legend_position_and_overlay_are_read() { let legend = r#""#; @@ -472,6 +524,23 @@ mod tests { assert_eq!(fmt(r#""#).as_deref(), Some("0.0")); } + #[test] + fn text_colour_is_read_from_the_axes() { + // LibreOffice's templates set light axis text for dark slides on the axes' c:txPr. + let xml = r#"1"#; + let c = read_chart_xml(xml.as_bytes()).unwrap(); + assert_eq!(c.text_color, Some(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))); + assert!(!c.cat_reversed); + let rev = read_chart_xml( + xml.replace("", "") + .as_bytes(), + ) + .unwrap(); + assert!(rev.cat_reversed, "maxMin reverses the category axis"); + // No c:gapWidth / c:overlap: the schema's 150 % and 0 %. + assert_eq!((c.gap_width, c.overlap), (1.5, 0.0)); + } + #[test] fn text_sizes_are_read_from_the_file() { // PowerPoint's new charts set the axes' and legend's text to 11.97 pt and the title's to diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs new file mode 100644 index 00000000..d8f3e9ce --- /dev/null +++ b/crates/pptx/src/read/chartex.rs @@ -0,0 +1,143 @@ +//! Office 2016 charts (`cx:chartSpace`, the `chartEx` part): waterfall, funnel, treemap, +//! sunburst, histogram, Pareto and box & whisker. Their data sits in `cx:chartData` as levels of +//! points, and each series names its layout (`layoutId`). + +use super::chart::rich_text; +use crate::xml::El; +use deckcraft_model::chart::{Chart, ChartType, Series}; +use std::collections::HashMap; + +/// Points per level and levels per dimension we keep (guards against hostile parts). +const MAX_POINTS: usize = 100_000; + +/// One `cx:data`: its categories (the innermost level) and named numeric dimensions. +#[derive(Default)] +struct Data { + categories: Vec, + /// Outer category levels, innermost first. + levels: Vec>, + numbers: HashMap>, Option)>, +} + +/// A level's points, by `idx`. +fn points(lvl: &El) -> Vec { + let n = lvl.u32("ptCount").unwrap_or(0).min(MAX_POINTS as u32) as usize; + let mut out = vec![String::new(); n]; + for pt in lvl.children_named("pt").take(MAX_POINTS) { + let i = pt.u32("idx").unwrap_or(0) as usize; + if i >= MAX_POINTS { + continue; + } + if i >= out.len() { + out.resize(i + 1, String::new()); + } + if let Some(s) = out.get_mut(i) { + *s = pt.text(); + } + } + out +} + +fn data(d: &El) -> Data { + let mut out = Data::default(); + for dim in d.elements() { + // The first level is the innermost: a treemap's leaves, a waterfall's categories. + let Some(lvl) = dim.child("lvl") else { continue }; + let ty = dim.attr("type").unwrap_or(""); + if dim.is("strDim") || (dim.is("numDim") && ty == "cat") { + if ty == "cat" || out.categories.is_empty() { + out.categories = points(lvl); + // Further levels are the hierarchy's parents, innermost first. + out.levels = dim.children_named("lvl").skip(1).take(deckcraft_model::chart::MAX_LEVELS).map(points).collect(); + } + } else if dim.is("numDim") { + let vals = points(lvl).into_iter().map(|s| s.trim().parse::().ok().filter(|v| v.is_finite())).collect(); + out.numbers.insert(ty.to_string(), (vals, lvl.attr("formatCode").map(str::to_string))); + } + } + out +} + +fn kind_of(layout: &str) -> Option { + Some(match layout { + "waterfall" => ChartType::Waterfall, + "funnel" => ChartType::Funnel, + "treemap" => ChartType::Treemap, + "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. +pub fn read_chartex_xml(bytes: &[u8]) -> Option { + let doc = crate::xml::parse(bytes).ok()?; + let mut root = doc.root; + super::resolve_mc(&mut root, 0); + let mut c = chartex_from(&root)?; + c.raw = Some(String::from_utf8_lossy(bytes).into_owned()); + Some(c) +} + +pub(crate) fn chartex_from(root: &El) -> Option { + if !root.is("chartSpace") { + return None; + } + let datas: HashMap = + root.child("chartData")?.children_named("data").map(|d| (d.attr("id").unwrap_or("0").to_string(), data(d))).collect(); + let ch = root.child("chart")?; + let region = ch.child("plotArea")?.child("plotAreaRegion")?; + let mut c = Chart { categories: vec![], series: vec![], title: None, legend: None, gridlines: false, ..Default::default() }; + c.title = ch.child("title").map(|t| rich_text(t).filter(|s| !s.is_empty()).unwrap_or_else(|| "Chart Title".into())); + c.legend = ch.child("legend").map(|l| l.attr("pos").unwrap_or("t").to_string()); + let mut kind = None; + for s in region.children_named("series") { + let layout = s.attr("layoutId").unwrap_or(""); + if layout == "paretoLine" { + // The cumulative line PowerPoint adds to a Pareto histogram. + kind = Some(ChartType::Pareto); + continue; + } + let k = kind_of(layout)?; + if s.bool("hidden") == Some(true) { + continue; + } + kind = Some(match kind { + Some(ChartType::Pareto) => ChartType::Pareto, + _ => k, + }); + let d = datas.get(s.child("dataId").and_then(|i| i.attr("val")).unwrap_or("0"))?; + let (values, format) = + ["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(); + } + if let Some(st) = s.child("layoutPr").and_then(|l| l.child("statistics")) { + c.inclusive_quartiles = st.attr("quartileMethod") == Some("inclusive"); + } + if let Some(b) = s.child("layoutPr").and_then(|l| l.child("binning")) { + c.bin_count = b.child("binCount").and_then(|n| n.u32("val")).filter(|n| *n > 0); + c.bin_size = b.child("binSize").and_then(|n| n.attr("val")).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0); + } + if let Some(st) = s.child("layoutPr").and_then(|l| l.child("subtotals")) { + c.subtotals = st.children_named("idx").filter_map(|i| i.u32("val")).map(|i| i as usize).take(MAX_POINTS).collect(); + } + let name = s.child("tx").and_then(|t| t.find("v")).map(|v| v.text()).unwrap_or_default(); + c.series.push(Series { + name, + values, + data_labels: s.child("dataLabels").map(|l| l.bool("hidden") != Some(true)), + number_format: format, + ..Default::default() + }); + } + c.kind = kind?; + c.data_labels = c.series.iter().any(|s| s.data_labels == Some(true)); + c.vary_colors = matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall); + // Waterfall, histogram and box charts have a value axis with gridlines by default. + c.gridlines = matches!(c.kind, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker); + Some(c) +} diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 4cc71e34..35b5aaa2 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -1,12 +1,14 @@ //! PPTX import: package → [`Presentation`]. -mod chart; +pub(crate) mod chart; +mod chartex; pub(crate) mod dml; mod shapes; mod theme; mod timing; pub(crate) use chart::{chart_from as chart_from_el, read_chart_xml}; +pub(crate) use chartex::{chartex_from, read_chartex_xml}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -155,7 +157,16 @@ pub fn keep_xml(el: &El, ns: &[(String, String)]) -> String { } /// Namespace prefixes whose `mc:Choice` content we understand. -const SUPPORTED_MC: [&str; 9] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188"]; +const SUPPORTED_MC: [&str; 10] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188", "cx1"]; + +/// Office 2016 charts sit in a `cx1` choice; take it only for a layout DeckCraft reads (the +/// fallback is a picture or "this chart isn't available" text). The part is checked later, when +/// the frame is read, so here only the namespace counts. +fn chartex_readable(ch: &El) -> bool { + let mut found = vec![]; + ch.find_all("graphicData", &mut found); + found.iter().any(|g| g.attr("uri").is_some_and(|u| u.ends_with("/chartex"))) +} /// A relationship to an embedded 3-D model (`.glb`). pub(crate) fn is_model3d(rel: &opc::Rel) -> bool { @@ -285,7 +296,8 @@ fn resolve_mc_rels(el: &mut El, depth: usize, rels: Option<&Rels>) { let choice = ac.children_named("Choice").find(|ch| { let req_ok = ch.attr("Requires").unwrap_or("").split_whitespace().all(|r| SUPPORTED_MC.contains(&r)); // Ink content parts are not modelled; prefer their fallback picture. - let modelled = !ch.elements().any(|e| e.is("contentPart")); + let modelled = !ch.elements().any(|e| e.is("contentPart")) + && (!ch.attr("Requires").unwrap_or("").contains("cx1") || chartex_readable(ch)); req_ok && modelled }); let branch = choice.or_else(|| ac.child("Fallback")); diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 83a183f6..738f2f48 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -422,7 +422,7 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S if let Some(c) = gd.child("chart") && let Some(rel) = part.rels.get(c.attr("r:id").unwrap_or("")) && let Some(bytes) = imp.pkg.xml_bytes(&rel.target) - && let Some(chart) = super::read_chart_xml(bytes) + && let Some(chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) } { s.kind = ShapeKind::Chart(Box::new(chart)); return s; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 4005c84f..3240677d 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -4,6 +4,9 @@ use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType} use deckcraft_model::style::Fill; use crate::opc::{NS_A, NS_C, NS_R}; + +/// The Office 2016 chart namespace. +pub const NS_CX: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex"; use crate::xml::{A, W}; /// Column letter for a 0-based index (A, B, … Z, AA…). @@ -118,11 +121,13 @@ enum Group { Scatter, Radar, Bubble, + Stock, } fn group_of(k: ChartType) -> Group { match k { - ChartType::Line | ChartType::LineMarkers | ChartType::StackedLine | ChartType::Stock => Group::Line, + ChartType::Line | ChartType::LineMarkers | ChartType::StackedLine => Group::Line, + ChartType::Stock => Group::Stock, ChartType::Area | ChartType::StackedArea | ChartType::Surface => Group::Area, ChartType::Pie => Group::Pie, ChartType::Doughnut | ChartType::Sunburst => Group::Doughnut, @@ -140,7 +145,32 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) w.open0("c:tx"); str_cache(w, &format!("Sheet1!${}$1", col(i + 1)), std::slice::from_ref(&s.name)); w.close("c:tx"); - sp_pr(w, s.fill.as_ref(), s.line.as_ref()); + if g == Group::Stock && s.line.is_none() { + // A stock chart's series are points joined by the high-low lines, not lines of their own. + w.open0("c:spPr"); + w.open("a:ln", A::new().a("w", 19050)); + w.empty0("a:noFill"); + w.close("a:ln"); + w.close("c:spPr"); + } else { + sp_pr(w, s.fill.as_ref(), s.line.as_ref()); + } + if g == Group::Stock { + // High-low-close: the close is a dash on the high-low line; with up/down bars (or a set + // marker) the series keep their own. + let close = !c.up_down_bars && i + 1 == c.series.len() && c.series.len() == 3; + let sym = s.marker.as_deref().filter(|m| !m.is_empty() && m.chars().all(|ch| ch.is_ascii_alphanumeric())).unwrap_or(if close { + "dash" + } else { + "none" + }); + w.open0("c:marker"); + w.val("c:symbol", sym); + if sym != "none" { + w.val("c:size", 7); + } + w.close("c:marker"); + } if g == Group::Bar { w.val("c:invertIfNegative", 0); } @@ -302,7 +332,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.open0("c:catAx"); w.val("c:axId", AX_CAT); w.open0("c:scaling"); - w.val("c:orientation", "minMax"); + w.val("c:orientation", if c.cat_reversed { "maxMin" } else { "minMax" }); w.close("c:scaling"); w.val("c:delete", 0); w.val("c:axPos", cat_pos); @@ -324,6 +354,9 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.open0("c:valAx"); w.val("c:axId", AX_VAL); w.open0("c:scaling"); + if let Some(b) = c.y_log_base { + w.val("c:logBase", fmt_num(b)); + } w.val("c:orientation", "minMax"); if let Some(m) = c.y_max.filter(|v| v.is_finite()) { w.val("c:max", fmt_num(m)); @@ -337,6 +370,9 @@ fn axes(w: &mut W, c: &Chart, g: Group) { if c.gridlines { w.empty0("c:majorGridlines"); } + if c.minor_gridlines { + w.empty0("c:minorGridlines"); + } if let Some(t) = &c.axis_titles.1 { title(w, t); } @@ -374,6 +410,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) Group::Scatter => "c:scatterChart", Group::Radar => "c:radarChart", Group::Bubble => "c:bubbleChart", + Group::Stock => "c:stockChart", }; w.open0(tag); match g { @@ -386,7 +423,10 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) Group::Radar => w.val("c:radarStyle", if kind == ChartType::FilledRadar { "filled" } else { "marker" }), _ => {} } - w.val("c:varyColors", vary); + // `c:stockChart` has no `c:varyColors`. + if g != Group::Stock { + w.val("c:varyColors", vary); + } for (i, s) in list { series(w, c, s, *i, kind, g); } @@ -402,6 +442,24 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) } } Group::Line => w.val("c:marker", 1), + Group::Stock => { + w.open0("c:hiLowLines"); + w.open0("c:spPr"); + w.open("a:ln", A::new().a("w", 9525)); + w.open0("a:solidFill"); + w.empty("a:schemeClr", A::new().a("val", "tx1")); + w.close("a:solidFill"); + w.close("a:ln"); + w.close("c:spPr"); + w.close("c:hiLowLines"); + if c.up_down_bars { + w.open0("c:upDownBars"); + w.val("c:gapWidth", 150); + w.empty0("c:upBars"); + w.empty0("c:downBars"); + w.close("c:upDownBars"); + } + } Group::Pie => w.val("c:firstSliceAng", 0), Group::Doughnut => { w.val("c:firstSliceAng", 0); @@ -416,10 +474,35 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) w.close(tag); } +/// The `chartEx` part of an Office 2016 chart (waterfall, funnel…): the kept part when the chart +/// is unchanged since it was read (without its workbook link), else one written from the model. +/// `None` for other chart types. +pub fn chartex_xml(c: &Chart) -> Option> { + kept_chartex(c).or_else(|| c.kind.is_chartex().then(|| build_chartex(c))) +} + +fn kept_chartex(c: &Chart) -> Option> { + let raw = c.raw.as_ref()?; + let doc = crate::xml::parse(raw.as_bytes()).ok()?; + let mut root = doc.root.clone(); + crate::read::resolve_mc(&mut root, 0); + let mut back = crate::read::chartex_from(&root)?; + back.raw = c.raw.clone(); + if &back != c { + return None; + } + let mut keep = doc.root; + keep.remove_all(&["externalData"]); + let mut w = W::new(); + w.raw(&keep.to_xml()); + Some(w.finish()) +} + /// The chart part XML. pub fn chart_xml(c: &Chart) -> Vec { // Reuse the kept part when the chart is unchanged since it was read. if let Some(raw) = &c.raw + && !raw.contains(NS_CX) && let Ok(doc) = crate::xml::parse(raw.as_bytes()) && doc.root.is("chartSpace") { @@ -501,6 +584,7 @@ pub fn chart_xml(c: &Chart) -> Vec { | ChartType::Bubble | ChartType::Radar | ChartType::FilledRadar + | ChartType::Stock ) { log::warn!("pptx: {} chart written as a column chart", c.kind.label()); } @@ -533,3 +617,219 @@ pub fn chart_xml(c: &Chart) -> Vec { w.close("c:chartSpace"); w.finish() } + +/// A `cx:tx` holding plain text. +fn cx_text(w: &mut W, t: &str) { + w.open0("cx:tx"); + w.open0("cx:txData"); + w.open0("cx:v"); + w.text(t); + w.close("cx:v"); + w.close("cx:txData"); + w.close("cx:tx"); +} + +/// A `cx:lvl` of points. +fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) { + let mut a = A::new().a("ptCount", pts.len()); + if let Some(f) = format { + a = a.a("formatCode", f); + } + w.open("cx:lvl", a); + for (i, p) in pts.iter().enumerate() { + w.open("cx:pt", A::new().a("idx", i)); + w.text(p); + w.close("cx:pt"); + } + 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. +pub fn build_chartex(c: &Chart) -> Vec { + let mut w = W::new(); + w.open("cx:chartSpace", A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:cx", NS_CX)); + w.open0("cx:chartData"); + for (i, s) in c.series.iter().enumerate() { + w.open("cx:data", A::new().a("id", i)); + if !c.categories.is_empty() { + w.open("cx:strDim", A::new().a("type", "cat")); + cx_level(&mut w, &c.categories, None); + for lv in &c.category_levels { + cx_level(&mut w, lv, None); + } + w.close("cx:strDim"); + } + 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"))); + w.close("cx:numDim"); + w.close("cx:data"); + } + w.close("cx:chartData"); + w.open0("cx:chart"); + if let Some(t) = &c.title { + w.open("cx:title", A::new().a("pos", "t").a("align", "ctr").a("overlay", "0")); + cx_text(&mut w, t); + w.close("cx:title"); + } + w.open0("cx:plotArea"); + w.open0("cx:plotAreaRegion"); + let layout = match c.kind { + ChartType::Waterfall => "waterfall", + ChartType::Funnel => "funnel", + ChartType::Treemap => "treemap", + ChartType::Sunburst => "sunburst", + ChartType::BoxWhisker => "boxWhisker", + ChartType::RegionMap => "regionMap", + _ => "clusteredColumn", + }; + let one = !matches!(c.kind, ChartType::BoxWhisker); + for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) { + w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}"))); + cx_text(&mut w, &s.name); + if s.data_labels.unwrap_or(c.data_labels) { + w.open("cx:dataLabels", A::new().a("pos", if c.kind == ChartType::Funnel { "ctr" } else { "outEnd" })); + w.empty("cx:visibility", A::new().a("seriesName", "0").a("categoryName", "0").a("value", "1")); + w.close("cx:dataLabels"); + } + w.empty("cx:dataId", A::new().a("val", i)); + match c.kind { + ChartType::Waterfall if !c.subtotals.is_empty() => { + w.open0("cx:layoutPr"); + w.open0("cx:subtotals"); + for t in &c.subtotals { + w.empty("cx:idx", A::new().a("val", t)); + } + w.close("cx:subtotals"); + w.close("cx:layoutPr"); + } + ChartType::Histogram | ChartType::Pareto => { + w.open0("cx:layoutPr"); + w.open("cx:binning", A::new().a("intervalClosed", "r")); + if let Some(size) = c.bin_size { + w.empty("cx:binSize", A::new().a("val", fmt_num(size))); + } else if let Some(n) = c.bin_count { + w.empty("cx:binCount", A::new().a("val", n)); + } + w.close("cx:binning"); + w.close("cx:layoutPr"); + } + ChartType::BoxWhisker => { + w.open0("cx:layoutPr"); + w.empty("cx:visibility", A::new().a("meanLine", "0").a("meanMarker", "1").a("nonoutliers", "0").a("outliers", "1")); + 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"); + } + if c.kind == ChartType::Pareto { + w.open("cx:series", A::new().a("layoutId", "paretoLine").a("ownerIdx", "0").a("uniqueId", "{DC0000FF-0000-4000-8000-000000000000}")); + w.empty("cx:axisId", A::new().a("val", "2")); + w.close("cx:series"); + } + w.close("cx:plotAreaRegion"); + 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"); + w.close("cx:axis"); + if c.kind != ChartType::Funnel { + w.open("cx:axis", A::new().a("id", "1")); + w.empty0("cx:valScaling"); + if c.gridlines { + w.empty0("cx:majorGridlines"); + } + w.empty0("cx:tickLabels"); + w.close("cx:axis"); + } + if c.kind == ChartType::Pareto { + w.open("cx:axis", A::new().a("id", "2")); + w.empty("cx:valScaling", A::new().a("max", "1").a("min", "0")); + w.empty0("cx:units"); + w.empty0("cx:tickLabels"); + w.close("cx:axis"); + } + } + w.close("cx:plotArea"); + if let Some(pos) = &c.legend { + w.empty("cx:legend", A::new().a("pos", pos.as_str()).a("align", "ctr").a("overlay", "0")); + } + w.close("cx:chart"); + 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(""#)); + assert!(crate::read::chart::read_chart_xml(&xml).unwrap().cat_reversed); + c.cat_reversed = false; + assert!(!crate::read::chart::read_chart_xml(&super::chart_xml(&c)).unwrap().cat_reversed); + } +} diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index d0acee46..45689d0e 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -1,6 +1,6 @@ //! PPTX export: [`Presentation`] → package. -mod chart; +pub(crate) mod chart; pub(crate) mod dml; mod shapes; mod theme; @@ -35,6 +35,8 @@ const CT_VIEW_PROPS: &str = "application/vnd.openxmlformats-officedocument.prese const CT_TABLE_STYLES: &str = "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml"; const CT_TAGS: &str = "application/vnd.openxmlformats-officedocument.presentationml.tags+xml"; const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; +const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml"; +const RT_CHARTEX: &str = "http://schemas.microsoft.com/office/2014/relationships/chartEx"; const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.presentationml.comments+xml"; const CT_AUTHORS: &str = "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml"; /// Modern threaded comments (read by PowerPoint 2021+ and DeckCraft; the flattened legacy @@ -56,6 +58,7 @@ pub struct Exp<'a> { n_chart: u32, n_diagram: u32, n_tags: u32, + n_chartex: u32, pub slide_parts: HashMap, pub table_styles: Vec, placeholder: Option, @@ -190,6 +193,15 @@ impl Exp<'_> { self.pkg.xml_part(&name, CT_TAGS, w.finish()); o.rels.add_new("tags", &opc::relative(&o.name, &name), false) } + /// An unchanged Office 2016 chart's `chartEx` part and its relationship id. + pub fn chartex_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> Option { + let xml = chart::chartex_xml(c)?; + self.n_chartex += 1; + let name = format!("ppt/charts/chartEx{}.xml", self.n_chartex); + self.pkg.xml_part(&name, CT_CHARTEX, xml); + Some(o.rels.add_new(RT_CHARTEX, &opc::relative(&o.name, &name), false)) + } + pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> String { self.n_chart += 1; let name = format!("ppt/charts/chart{}.xml", self.n_chart); @@ -509,6 +521,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result, PptxError> { n_chart: 0, n_diagram: 0, n_tags: 0, + n_chartex: 0, slide_parts: HashMap::new(), table_styles: vec![], placeholder: None, diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 5b03288a..085282f8 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -385,12 +385,32 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } ShapeKind::Chart(c) => { let id = cx.id_for(s); - let rid = x.chart_rel(o, c); + // An unchanged Office 2016 chart goes back as itself, with a plain chart of the same + // data for readers without `cx1`, as PowerPoint writes it. + let ex = x.chartex_rel(o, c); + if let Some(exid) = &ex { + w.open("mc:AlternateContent", A::new().a("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006")); + w.open("mc:Choice", A::new().a("xmlns:cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex").a("Requires", "cx1")); + frame_open(w, x, o, id, s); + w.open("a:graphicData", A::new().a("uri", super::chart::NS_CX)); + w.empty("cx:chart", A::new().a("xmlns:cx", super::chart::NS_CX).a("r:id", exid)); + w.close("a:graphicData"); + frame_close(w); + w.close("mc:Choice"); + w.open0("mc:Fallback"); + } + let mut plain = (**c).clone(); + plain.raw = plain.raw.filter(|r| !r.contains(super::chart::NS_CX)); + let rid = x.chart_rel(o, &plain); frame_open(w, x, o, id, s); w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart")); w.empty("c:chart", A::new().a("xmlns:c", crate::opc::NS_C).a("r:id", rid)); w.close("a:graphicData"); frame_close(w); + if ex.is_some() { + w.close("mc:Fallback"); + w.close("mc:AlternateContent"); + } } ShapeKind::Media(m) => { let id = cx.id_for(s); diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 88848239..3db394be 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -1410,3 +1410,243 @@ fn notes_master_placeholders_and_colour_map_survive_save() { assert_eq!(sh.text.as_ref().map(|t| t.text()), Some("Company confidential".to_string())); assert_eq!(nm.color_map, map); } + +/// A part's bytes, if the package has it. +fn pkg_part(pkg: &[u8], name: &str) -> Option> { + use std::io::Read; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap(); + let mut f = z.by_name(name).ok()?; + let mut v = vec![]; + f.read_to_end(&mut v).unwrap(); + Some(v) +} + +/// Rewrite a package's parts with `edit`, then add `extra` parts. +fn repack(pkg: &[u8], edit: impl Fn(&str, String) -> String, extra: &[(&str, &[u8])]) -> Vec { + use std::io::{Read, Write}; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).unwrap(); + if name.ends_with(".xml") || name.ends_with(".rels") { + data = edit(&name, String::from_utf8(data).unwrap()).into_bytes(); + } + out.start_file(name, opts).unwrap(); + out.write_all(&data).unwrap(); + } + for (name, data) in extra { + out.start_file(*name, opts).unwrap(); + out.write_all(data).unwrap(); + } + out.finish().unwrap().into_inner() +} + +const WATERFALL_CX: &str = r#" +Sheet1!$A$2:$A$5StartSalesCostsEndSheet1!$B$2:$B$510040-25115Cash flowSheet1!$B$1Amount"#; + +/// A waterfall frame as PowerPoint writes it: the chart in a `cx1` choice, a text box fallback. +const WATERFALL_FRAME: &str = r#"This chart isn't available in your version of PowerPoint."#; + +/// Office 2016 charts (`chartEx`): a waterfall reads as a waterfall chart with its data, not as +/// the "isn't available" fallback text, and an unchanged one is written back as itself with a +/// plain chart fallback; an edited one is written as a plain chart. +#[test] +fn chartex_waterfall_reads_and_round_trips() { + use deckcraft_engine::Session; + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + let base = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let slide = (1..=9).map(|i| format!("ppt/slides/slide{i}.xml")).find(|n| pkg_part(&base, n).is_some()).unwrap(); + let slide_rels = slide.replace("slides/", "slides/_rels/") + ".rels"; + let cx_rels = r#""#; + let pptx = repack( + &base, + |name, x| { + if name == slide { + x.replacen("", &format!("{WATERFALL_FRAME}"), 1) + } else if name == slide_rels { + x.replace("", r#""#) + } else if name == "[Content_Types].xml" { + x.replace( + "", + r#""#, + ) + } else { + x + } + }, + &[("ppt/charts/chartEx1.xml", WATERFALL_CX.as_bytes()), ("ppt/charts/_rels/chartEx1.xml.rels", cx_rels.as_bytes())], + ); + let doc = deckcraft_pptx::import(&pptx).unwrap(); + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + let c = chart(&doc).expect("the waterfall is a chart, not the fallback text"); + assert_eq!(c.kind, ChartType::Waterfall); + assert_eq!(c.title.as_deref(), Some("Cash flow")); + assert_eq!(c.categories, ["Start", "Sales", "Costs", "End"]); + assert_eq!(c.series.len(), 1); + assert_eq!(c.series[0].name, "Amount"); + assert_eq!(c.series[0].values, [Some(100.0), Some(40.0), Some(-25.0), Some(115.0)]); + assert!(c.data_labels); + assert_eq!(c.legend.as_deref(), Some("b")); + let no_fallback_text = doc.slides.iter().flat_map(|s| s.shapes.iter()).all(|sh| !format!("{:?}", sh.kind).contains("isn't available")); + assert!(no_fallback_text); + + // Unchanged: chartEx back in a cx1 choice, a plain chart as fallback, no workbook link. + let out = deckcraft_pptx::export(&doc).unwrap(); + let ex = String::from_utf8(pkg_part(&out, "ppt/charts/chartEx1.xml").expect("chartEx part")).unwrap(); + assert!(ex.contains(r#"layoutId="waterfall""#) && !ex.contains("externalData"), "{ex}"); + let types = String::from_utf8(pkg_part(&out, "[Content_Types].xml").unwrap()).unwrap(); + assert!(types.contains("application/vnd.ms-office.chartex+xml")); + let sx = String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap(); + assert!(sx.contains(r#"Requires="cx1""#) && sx.contains("mc:Fallback") && sx.contains("c:chart "), "{sx}"); + let srels = String::from_utf8(pkg_part(&out, &slide_rels).unwrap()).unwrap(); + assert!(srels.contains("relationships/chartEx") && srels.contains("relationships/chart\""), "{srels}"); + assert!(pkg_part(&out, "ppt/charts/chart1.xml").is_some()); + let again = deckcraft_pptx::import(&out).unwrap(); + assert_eq!(chart(&again).map(|c| (c.kind, c.categories, c.series.len())), Some((ChartType::Waterfall, c.categories.clone(), 1))); + + // Edited: the kept part no longer matches, so a chartEx part is written from the model, + // still a waterfall with the new value. + let mut edited = doc.clone(); + for sl in &mut edited.slides { + for sh in &mut Arc::make_mut(sl).shapes { + if let ShapeKind::Chart(ch) = &mut sh.kind { + ch.series[0].values[1] = Some(55.0); + } + } + } + let out = deckcraft_pptx::export(&edited).unwrap(); + let ex = String::from_utf8(pkg_part(&out, "ppt/charts/chartEx1.xml").expect("a written chartEx part")).unwrap(); + assert!(ex.contains(r#"layoutId="waterfall""#), "{ex}"); + assert!(String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap().contains("cx1")); + let again = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(again.kind, ChartType::Waterfall); + assert_eq!(again.series[0].values[1], Some(55.0)); + assert_eq!(again.subtotals, c.subtotals); +} + +/// New Office 2016 charts made in DeckCraft are written as chartEx parts that read back as the +/// same type, data and options. +#[test] +fn chartex_new_charts_save_as_their_own_type() { + use deckcraft_engine::Session; + for (ty, kind) in [ + ("waterfall", ChartType::Waterfall), + ("funnel", ChartType::Funnel), + ("treemap", ChartType::Treemap), + ("sunburst", ChartType::Sunburst), + ("histogram", ChartType::Histogram), + ("pareto", ChartType::Pareto), + ("boxwhisker", ChartType::BoxWhisker), + ] { + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap(); + let id = s.doc().unwrap().selection.shapes[0].0; + let opts = match kind { + ChartType::Histogram | ChartType::Pareto => serde_json::json!({"id": id, "binCount": 4, "title": "T"}), + ChartType::Waterfall => serde_json::json!({"id": id, "subtotals": [3], "title": "T"}), + _ => serde_json::json!({"id": id, "title": "T"}), + }; + s.execute("chart.options", &opts).unwrap(); + let doc = &s.doc().unwrap().doc; + let before = chart(doc).unwrap(); + let out = deckcraft_pptx::export(doc).unwrap(); + assert!(pkg_part(&out, "ppt/charts/chartEx1.xml").is_some(), "{ty}: no chartEx part"); + assert!(pkg_part(&out, "ppt/charts/chart1.xml").is_some(), "{ty}: no fallback chart"); + let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(after.kind, kind, "{ty}"); + assert_eq!(after.title.as_deref(), Some("T"), "{ty}"); + assert_eq!(after.categories, before.categories, "{ty}"); + let n = if matches!(kind, ChartType::BoxWhisker) { before.series.len() } else { 1 }; + assert_eq!(after.series.len(), n, "{ty}"); + assert_eq!(after.series[0].values, before.series[0].values, "{ty}"); + assert_eq!((after.bin_count, after.subtotals.clone()), (before.bin_count, before.subtotals.clone()), "{ty}"); + } +} + +/// A two-level treemap keeps its parent level, and a box chart its inclusive quartiles, through +/// a save and reopen. +#[test] +fn chartex_levels_and_quartiles_round_trip() { + use deckcraft_engine::Session; + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + for ty in ["treemap", "sunburst", "boxwhisker"] { + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap(); + let id = s.doc().unwrap().selection.shapes[0].0; + if ty == "boxwhisker" { + s.execute("chart.options", &serde_json::json!({"id": id, "quartiles": "inclusive"})).unwrap(); + } else { + s.execute( + "chart.data", + &serde_json::json!({"id": id, "levels": [["Q1", "", "", "Q2", "", "", "Q3", ""], ["H1", "", "", "", "", "", "H2", ""]]}), + ) + .unwrap(); + } + let before = chart(&s.doc().unwrap().doc).unwrap(); + let out = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(after.category_levels, before.category_levels, "{ty}"); + assert_eq!(after.inclusive_quartiles, before.inclusive_quartiles, "{ty}"); + assert_eq!(after.category_tree(), before.category_tree(), "{ty}"); + // Unchanged since import, the part is written back as read. + let again = deckcraft_pptx::export(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(chart(&deckcraft_pptx::import(&again).unwrap()).unwrap().category_levels, before.category_levels, "{ty}"); + } +} + +/// Radar, stock (with and without up/down bars) and combo charts keep their own plot groups +/// through a save and reopen, written as PowerPoint writes them. +#[test] +fn radar_stock_and_combo_round_trip() { + use deckcraft_engine::Session; + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + for (ty, bars) in [("radar", false), ("filledradar", false), ("stock", false), ("stock", true), ("combo", false)] { + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap(); + let id = s.doc().unwrap().selection.shapes[0].0; + if bars { + s.execute("chart.options", &serde_json::json!({"id": id, "upDownBars": true})).unwrap(); + } + let before = chart(&s.doc().unwrap().doc).unwrap(); + let out = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(after.kind, before.kind, "{ty}"); + assert_eq!(after.up_down_bars, bars, "{ty}"); + assert_eq!(after.series.len(), before.series.len(), "{ty}"); + let kinds: Vec<_> = after.series.iter().map(|x| after.series_kind(x)).collect(); + let want: Vec<_> = before.series.iter().map(|x| before.series_kind(x)).collect(); + assert_eq!(kinds, want, "{ty}"); + for (a, b) in after.series.iter().zip(&before.series) { + assert_eq!(a.values, b.values, "{ty}"); + } + } +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index efa060c7..c6f35fdc 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -2,7 +2,7 @@ use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_model::Shape; -use deckcraft_model::chart::{Chart, ChartType, axis_scale}; +use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale}; use deckcraft_model::resolve::Ctx; use deckcraft_model::style::{ColorRef, Fill}; use deckcraft_model::text::{Align, Anchor, Bullet, TextBody}; @@ -29,16 +29,53 @@ pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba { } } -fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba { +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), _ => 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)), + } +} + +fn polyline(pts: &[Point]) -> BezPath { + let mut p = BezPath::new(); + for (k, q) in pts.iter().enumerate() { + if k == 0 { + p.move_to(*q); + } else { + p.line_to(*q); + } + } + p +} + +/// A smoothed line (`c:smooth`) through `pts`: a Catmull-Rom spline as cubic Béziers, the curve +/// PowerPoint draws, passing through every point. +pub(crate) fn smooth_path(pts: &[Point]) -> BezPath { + let mut p = BezPath::new(); + let Some(first) = pts.first() else { return p }; + p.move_to(*first); + for k in 0..pts.len() - 1 { + let p0 = pts[k.saturating_sub(1)]; + let (p1, p2) = (pts[k], pts[k + 1]); + let p3 = pts[(k + 2).min(pts.len() - 1)]; + let c1 = p1 + (p2 - p0) / 6.0; + let c2 = p2 - (p3 - p1) / 6.0; + p.curve_to(c1, c2, p2); + } + p +} + /// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the /// palette's. -fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba { +pub(crate) fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba { match s.line.as_ref().and_then(|l| l.fill.as_ref()) { Some(Fill::Solid { color: cc }) => ctx.color(cc, None), _ => series_fill(ctx, c, i, Some(s)), @@ -59,7 +96,18 @@ fn split_label(name: &str) -> String { } } -fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) { +pub(crate) fn label( + ctx: &mut RenderContext, + rctx: &Ctx, + m: Affine, + text: &str, + r: Rect, + size: f64, + align: Align, + anchor: Anchor, + col: Rgba, + bold: bool, +) { let mut body = TextBody::from_text(text); body.body.inset_l = Some(0.0); body.body.inset_r = Some(0.0); @@ -82,6 +130,21 @@ fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, si crate::draw_text(ctx, rctx, &tmp, &body, r, m, &deckcraft_text::NoFields, None); } +fn fmt_num(v: f64) -> String { + if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { format!("{v:.1}") } +} + +/// A scatter or bubble chart's value x axis (min, max, major unit): one scale over every series' +/// x values, from zero when they're all positive. `None` for other charts and when there are no x values. +fn value_x_scale(c: &Chart, max_intervals: usize) -> Option<(f64, f64, f64)> { + if c.series.is_empty() || !c.series.iter().all(|s| matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble)) { + return None; + } + let xs = c.series.iter().flat_map(|s| s.x_values.iter().flatten().copied()).filter(|v| v.is_finite()); + let (lo, hi) = xs.fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, b)| (a.min(v), b.max(v)))))?; + Some(axis_scale(lo.min(0.0), hi, None, None, None, max_intervals, false)) +} + /// Where the legend goes for `c:legendPos`, and the plot area left over. /// `size` is the legend block (width, height); the result is its top-left corner and the plot area. /// `r` (also the OOXML default and the fallback for unknown values) and `l` are vertical stacks centred @@ -119,6 +182,19 @@ fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bo } pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { + match c.kind { + // Histograms and Pareto charts are drawn as the column (and line) chart of their bins. + ChartType::Histogram | ChartType::Pareto => draw_inner(ctx, rctx, &c.histogram_view(), m, w, h, None), + ChartType::BoxWhisker => { + let (view, boxes) = crate::chartex::box_view(c); + draw_inner(ctx, rctx, &view, m, w, h, Some(&boxes)); + } + _ => draw_inner(ctx, rctx, c, m, w, h, None), + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59)); let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); if let Some(f) = &c.chart_fill { @@ -146,7 +222,16 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); // Legend placed by `c:legendPos`. - let legend_items: Vec<(String, Rgba)> = if pie { + 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() + } 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 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() } else { c.series @@ -238,6 +323,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } return; } + if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) { + crate::chartex::draw(ctx, rctx, c, m, area, base, text_col); + return; + } + if matches!(c.kind, ChartType::Radar | ChartType::FilledRadar) { + crate::radar::draw(ctx, rctx, c, m, area, base, text_col); + return; + } let combo = c.kind == ChartType::Combo; // Bars are drawn first and lines over them; a combo chart has both. let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like()); @@ -262,7 +355,16 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h // As many major units as the value axis has room for labels (PowerPoint's automatic unit). let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 }; let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize; - let (min, max, step) = c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true)); + // A logarithmic axis is scaled in exponents of its base. + let log = c.log_axis_scale(max_intervals).map(|sc| (c.y_log_base.unwrap_or(10.0), sc)); + let (min, max, step) = match log { + Some((_, sc)) => sc, + None => c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true)), + }; + let to_e = |v: f64| match log { + Some((b, _)) => v.max(f64::MIN_POSITIVE).log(b).max(min - 1.0), + None => v, + }; // A combo chart's secondary value axis, on the right (vertical value axes only). let second = if horiz { None } else { c.axis_scale(true, max_intervals) }; let ticks = |(lo, hi, st): (f64, f64, f64)| { @@ -270,8 +372,11 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h (0..=n).map(move |k| lo + k as f64 * st) }; // Label gutters as wide as the longest value label. - let label_w = - |sec: bool, sc: (f64, f64, f64)| ticks(sc).map(|v| c.axis_label(sec, v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6; + let primary_ticks: Vec = ticks((min, max, step)).map(|k| log.map_or(k, |(b, _)| b.powf(k))).collect(); + let label_w = |sec: bool, sc: (f64, f64, f64)| { + let vals: Vec = if sec { ticks(sc).collect() } else { primary_ticks.clone() }; + vals.iter().map(|v| c.axis_label(sec, *v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6 + }; let gutter = if horiz { base * 4.5 } else if c.y_hidden { @@ -292,7 +397,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h // Values past the axis limits (fixed ones, or zero on an axis that starts above it) stop at // the plot's edge. let to_v = |v: f64| { - let v = v.max(min).min(max); + let v = to_e(v).max(min).min(max); if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() } }; // A series' value as a position on its own axis. @@ -300,8 +405,30 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h (true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / (max2 - min2).max(1e-9) * plot.height(), _ => to_v(v), }; + // Minor gridlines, under the major ones: five to a major unit. + if c.minor_gridlines { + let minor = step / 5.0; + let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500); + let mut g = BezPath::new(); + for k in (0..=n).filter(|k| k % 5 != 0) { + // On a logarithmic axis `min`, `max` and `step` are exponents of the base. + let e = min + k as f64 * minor; + let pos = to_v(log.map_or(e, |(b, _)| b.powf(e))); + if horiz { + g.move_to((pos, plot.y0)); + g.line_to((pos, plot.y1)); + } else { + g.move_to((plot.x0, pos)); + g.line_to((plot.x1, pos)); + } + } + ctx.set_transform(m); + ctx.set_paint(color(Rgba::rgb(0xF2, 0xF2, 0xF2), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&g); + } // Gridlines and value labels. - for v in ticks((min, max, step)) { + for &v in &primary_ticks { let pos = to_v(v); let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) }; let mut g = BezPath::new(); @@ -362,11 +489,41 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); - let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 }; + // Forecast reserves empty category slots so the line can run past the data. Charts with no + // forecast keep the old band. + let (back, fwd) = forecast_pad(c); + let nspan = ncat as f64 + back + fwd; + let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; + // Where category `i`'s band starts along its axis: left to right, or bottom to top on a + // horizontal bar chart, unless the axis is reversed. The forecast's empty slots stay on + // their own side of the data. + let backwards = horiz != c.cat_reversed; + let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back }; + // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its + // own tick labels in place of category names. + let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; + let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / (hi - lo).max(1e-9) * plot.width()); + if let Some(sc) = xscale { + for v in ticks(sc) { + let pos = plot.x0 + (v - sc.0) / (sc.1 - sc.0).max(1e-9) * plot.width(); + label( + ctx, + rctx, + m, + &fmt_num(v), + Rect::new(pos - 30.0, plot.y1 + 3.0, pos + 30.0, plot.y1 + base * 1.6), + base, + Align::Center, + Anchor::Top, + text_col, + false, + ); + } + } // Category labels: wrapped onto two lines when they don't fit their band (long date ranges). let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band); - for (i, name) in c.categories.iter().enumerate() { - let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) }; + for (i, name) in c.categories.iter().enumerate().filter(|_| xscale.is_none()) { + let mid = if horiz { plot.y0 + band * (slot(i) + 0.5) } else { plot.x0 + band * (slot(i) + 0.5) }; if horiz { label( ctx, @@ -396,15 +553,27 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ); } } - let zero = to_v(clamp_soft(0.0, min, max)); + if let Some(b) = boxes { + crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); + return; + } + let zero = match log { + Some((b, _)) => to_v(b.powf(min)), + None => to_v(clamp_soft(0.0, min, max)), + }; if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect(); let ns = bars.len().max(1); + // Gap width and overlap are fractions of one bar's width: a category's band holds its bars + // (overlapping by `overlap` of a bar between neighbours) plus one gap. let gap = c.gap_width.clamp(0.0, 5.0); - let group = band / (1.0 + gap); let stacked = bk.stacked(); - let bar = if stacked { group } else { group / ns as f64 }; + let ov = if stacked { 1.0 } else { c.overlap.clamp(-1.0, 1.0) }; + let n = ns as f64; + let bar = band / (n - (n - 1.0) * ov + gap); + let step = bar * (1.0 - ov); + let group = bar + step * (n - 1.0); for i in 0..ncat { let mut pos_acc = 0.0; let mut neg_acc = 0.0; @@ -416,8 +585,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h for (bi, (si, s)) in bars.iter().enumerate() { let sec = c.on_secondary(s); let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total; - let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 }; - let (a, b) = if stacked { + let start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 }; + let (a, b) = if waterfall { + c.waterfall_bars().get(i).copied().unwrap_or_default() + } else if stacked { if v >= 0.0 { let r = (pos_acc, pos_acc + v); pos_acc += v; @@ -434,10 +605,34 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h Rect::new(to_v(a), plot.y0 + start, to_v(b), plot.y0 + start + bar) } else { Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a)) + } + .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 slot = if c.subtotals.contains(&i) { + 2 + } else if b < a { + 1 + } else { + 0 + }; + series_color(rctx, c, slot) + } else { + point_fill(rctx, c, *si, s, i) }; ctx.set_transform(m); - ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0)); + ctx.set_paint(color(fill, 1.0)); ctx.fill_rect(&r.abs()); + if waterfall && i + 1 < ncat && !horiz { + let y = to_v(b); + let mut j = BezPath::new(); + j.move_to((r.x1, y)); + j.line_to((r.x1 + (band - bar), y)); + ctx.set_paint(color(Rgba::rgb(0xA6, 0xA6, 0xA6), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&j); + } if c.data_labels { let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0)); let lr = if horiz { @@ -461,11 +656,17 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } } - // Lines, areas, scatter, radar (drawn as lines): the series that aren't bars. + // A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series. + if c.kind == ChartType::Stock { + for i in 0..ncat { + crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v)); + } + } + // Lines, areas, scatter: the series that aren't bars. let mut acc = vec![0.0; ncat]; for (si, s) in c.series.iter().enumerate() { let kind = c.series_kind(s); - if kind.is_bar_like() { + if kind.is_bar_like() || kind == ChartType::Stock { continue; } let sec = c.on_secondary(s); @@ -486,26 +687,21 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h }; let x = match kind { ChartType::Scatter | ChartType::Bubble => { - let xs: Vec = s.x_values.iter().flatten().copied().collect(); - let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)); - let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64); - if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) } + match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { + (Some(x), _) => x, + // No x values at all: the points are numbered 1, 2, 3... + (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), + (None, Some(_)) => continue, + } } - _ => plot.x0 + band * (i as f64 + 0.5), + _ => plot.x0 + band * (slot(i) + 0.5), }; pts.push(Point::new(x, to_axis(sec, v))); } if pts.is_empty() { continue; } - let mut p = BezPath::new(); - for (k, q) in pts.iter().enumerate() { - if k == 0 { - p.move_to(*q); - } else { - p.line_to(*q); - } - } + let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) }; ctx.set_transform(m); if area_kind { let mut a = p.clone(); @@ -529,6 +725,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } } + // A category position (0-based, fractional) as a slot, the way `slot` places the bars. + let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; + draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis); // Axis line. let mut ax = BezPath::new(); if horiz { @@ -551,12 +750,273 @@ pub(crate) fn clamp_soft(v: f64, lo: f64, hi: f64) -> f64 { if v > hi { hi } else { v } } +/// A corner of `plot` whose box stays clear of `pts` (the drawn trendline). +fn caption_at(plot: Rect, box_w: f64, box_h: f64, pts: &[Point], n: usize) -> (f64, f64) { + let pad = 4.0; + let shift = n as f64 * (box_h + 2.0); + let x_right = (plot.x1 - box_w - pad).max(plot.x0 + pad); + let y_bottom = (plot.y1 - box_h - pad - shift).max(plot.y0 + pad); + let y_top = (plot.y0 + pad + shift).min(y_bottom); + let corners = [(plot.x0 + pad, y_top), (x_right, y_top), (plot.x0 + pad, y_bottom), (x_right, y_bottom)]; + let mut best = corners[0]; + let mut best_d = -1.0; + for (x, y) in corners { + let mut min_d = f64::MAX; + for p in pts { + let dx = if p.x < x { + x - p.x + } else if p.x > x + box_w { + p.x - (x + box_w) + } else { + 0.0 + }; + let dy = if p.y < y { + y - p.y + } else if p.y > y + box_h { + p.y - (y + box_h) + } else { + 0.0 + }; + min_d = min_d.min((dx * dx + dy * dy).sqrt()); + } + if pts.is_empty() { + min_d = 1.0; + } + if min_d > best_d { + best_d = min_d; + best = (x, y); + } + } + best +} + +fn pad_units(v: Option) -> f64 { + v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(3.0)).unwrap_or(0.0) +} + +/// Empty category slots reserved for a trendline forecast, capped so the data still fills the plot. +fn forecast_pad(c: &Chart) -> (f64, f64) { + if matches!(c.kind, ChartType::Scatter | ChartType::Bubble) { + return (0.0, 0.0); + } + let mut back = 0.0_f64; + let mut fwd = 0.0_f64; + for s in &c.series { + if matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble) { + continue; + } + for t in s.trendlines.iter().take(8) { + if t.kind == TrendlineType::MovingAvg { + continue; + } + back = back.max(pad_units(t.backward)); + fwd = fwd.max(pad_units(t.forward)); + } + } + (back, fwd) +} + +const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, 0x30), Rgba::rgb(0x1A, 0x5C, 0x7A), Rgba::rgb(0xA0, 0x68, 0x00)]; + +fn map_xy( + plot: Rect, + band: f64, + slot: &impl Fn(f64) -> f64, + horiz: bool, + scatter: bool, + sx0: f64, + sx1: f64, + sec: bool, + x: f64, + y: f64, + to_axis: &impl Fn(bool, f64) -> f64, +) -> Option { + if !x.is_finite() || !y.is_finite() { + return None; + } + let value = to_axis(sec, y); + if !value.is_finite() { + return None; + } + let along = if scatter && sx1 > sx0 { + plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width() + } else { + let origin = if horiz { plot.y0 } else { plot.x0 }; + origin + band * (slot(x - 1.0) + 0.5) + }; + if !along.is_finite() { + return None; + } + Some(if horiz && !scatter { Point::new(value, along) } else { Point::new(along, value) }) +} + +/// The part of segment `a`–`b` that lies inside `r`. +fn clip_seg(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { + let (mut t0, mut t1) = (0.0, 1.0); + let dx = b.x - a.x; + let dy = b.y - a.y; + let p = [-dx, dx, -dy, dy]; + let q = [a.x - r.x0, r.x1 - a.x, a.y - r.y0, r.y1 - a.y]; + for i in 0..4 { + let (pi, qi) = (p[i], q[i]); + if pi.abs() < 1e-12 { + if qi < 0.0 { + return None; + } + } else { + let t = qi / pi; + if pi < 0.0 { + if t > t1 { + return None; + } else if t > t0 { + t0 = t; + } + } else if t < t0 { + return None; + } else if t < t1 { + t1 = t; + } + } + } + Some((Point::new(a.x + t0 * dx, a.y + t0 * dy), Point::new(a.x + t1 * dx, a.y + t1 * dy))) +} + +fn draw_trendlines( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + plot: Rect, + band: f64, + slot: &impl Fn(f64) -> f64, + xscale: Option<(f64, f64, f64)>, + ncat: usize, + horiz: bool, + base: f64, + text_col: Rgba, + to_axis: &impl Fn(bool, f64) -> f64, +) { + let mut label_n = 0usize; + for s in &c.series { + if s.trendlines.is_empty() { + continue; + } + let kind = c.series_kind(s); + let scatter = matches!(kind, ChartType::Scatter | ChartType::Bubble); + let sec = c.on_secondary(s); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for i in 0..ncat { + let Some(y) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + let x = if scatter { + let Some(x) = s.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + x + } else { + (i + 1) as f64 + }; + xs.push(x); + ys.push(y); + } + // The same x extent the scatter marks use, so the fit sits on the points: the chart's + // value x axis where it has one, else the series' own x values. + let (sx0, sx1) = if let (true, Some((lo, hi, _))) = (scatter, xscale) { + (lo, hi) + } else if scatter { + let mut lo = f64::MAX; + let mut hi = f64::MIN; + for v in s.x_values.iter().copied().flatten().filter(|v| v.is_finite()) { + lo = lo.min(v); + hi = hi.max(v); + } + (lo, hi) + } else { + (0.0, 0.0) + }; + for (ti, t) in s.trendlines.iter().take(8).enumerate() { + let Some(fit) = t.fit(&xs, &ys) else { continue }; + let (x0, x1) = fit.span(t.forward, t.backward); + let samples = fit.samples(x0, x1); + let mut path = BezPath::new(); + let mut prev: Option = None; + let mut ink_pts = Vec::new(); + let mut any = false; + for w in samples.windows(2) { + let (Some(a), Some(b)) = ( + map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), + map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), + ) else { + prev = None; + continue; + }; + let Some((ca, cb)) = clip_seg(a, b, plot) else { + prev = None; + continue; + }; + let cont = prev.is_some_and(|p| (p.x - ca.x).abs() < 0.6 && (p.y - ca.y).abs() < 0.6); + if cont { + path.line_to(cb); + } else { + path.move_to(ca); + path.line_to(cb); + } + prev = Some(cb); + if ink_pts.len() < 64 { + ink_pts.push(ca); + ink_pts.push(cb); + } + any = true; + } + if !any { + continue; + } + ctx.set_transform(m); + let ink = TREND_INK.get(ti % TREND_INK.len()).copied().unwrap_or(Rgba::rgb(0x22, 0x22, 0x22)); + ctx.set_paint(color(ink, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.6).with_dashes(0.0, [6.0, 3.5]).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&path); + let caption = t.caption(&fit); + if caption.is_empty() { + continue; + } + let lines = caption.chars().filter(|ch| *ch == '\n').count() + 1; + let box_h = base * 1.3 * lines as f64 + 2.0; + let widest = caption.lines().map(|l| l.chars().count()).max().unwrap_or(1) as f64; + let box_w = clamp_soft(widest * base * 0.62, 48.0, plot.width() * 0.72); + let (x, y) = caption_at(plot, box_w, box_h, &ink_pts, label_n); + ctx.set_transform(m); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.fill_rect(&Rect::new(x, y, x + box_w, y + box_h)); + label( + ctx, + rctx, + m, + &caption, + Rect::new(x, y, x + box_w, y + box_h), + (base * 0.9).clamp(8.0, 13.0), + Align::Left, + Anchor::Top, + text_col, + false, + ); + label_n += 1; + } + } +} + #[cfg(test)] mod tests { use super::*; const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0); + #[test] + fn scatter_x_is_a_value_scale_over_all_series() { + let mut c = Chart::sample(ChartType::Scatter); + c.series[0].x_values = vec![Some(0.5), Some(7.2), None]; + assert_eq!(value_x_scale(&c, 8), Some((0.0, 8.0, 1.0))); + assert_eq!(value_x_scale(&Chart::sample(ChartType::Column), 8), None); + } + #[test] fn right_legend_is_a_centred_column_and_narrows_the_plot() { let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, false); @@ -586,4 +1046,36 @@ mod tests { assert_eq!(plot, AREA); assert_eq!(o.x, 340.0); } + + #[test] + fn clip_keeps_the_part_inside_the_plot() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let (a, b) = clip_seg(Point::new(-50.0, 25.0), Point::new(150.0, 25.0), r).expect("clipped"); + assert!((a.x - 0.0).abs() < 1e-6 && (b.x - 100.0).abs() < 1e-6); + assert!((a.y - 25.0).abs() < 1e-6); + assert!(clip_seg(Point::new(-10.0, -10.0), Point::new(-1.0, -1.0), r).is_none()); + let (a, b) = clip_seg(Point::new(10.0, 10.0), Point::new(40.0, 20.0), r).expect("inside"); + assert!((a.x - 10.0).abs() < 1e-6 && (b.x - 40.0).abs() < 1e-6); + } + + #[test] + fn caption_keeps_clear_of_a_line_into_the_top_right() { + let plot = Rect::new(0.0, 0.0, 200.0, 100.0); + let pts = [Point::new(20.0, 80.0), Point::new(180.0, 15.0)]; + let (x, y) = caption_at(plot, 60.0, 20.0, &pts, 0); + assert!(x > 100.0 && y > 40.0, "label on the line at {x} {y}"); + } + + #[test] + fn forecast_reserves_at_most_three_category_slots() { + use deckcraft_model::chart::Trendline; + let mut c = Chart::sample(ChartType::Line); + c.series[0].trendlines.push(Trendline { forward: Some(10.0), backward: Some(0.5), ..Default::default() }); + assert_eq!(forecast_pad(&c), (0.5, 3.0)); + c.series[0].trendlines[0].kind = TrendlineType::MovingAvg; + assert_eq!(forecast_pad(&c), (0.0, 0.0)); + c.kind = ChartType::Scatter; + c.series[0].trendlines[0].kind = TrendlineType::Linear; + assert_eq!(forecast_pad(&c), (0.0, 0.0)); + } } diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs new file mode 100644 index 00000000..e62105e8 --- /dev/null +++ b/crates/render/src/chartex.rs @@ -0,0 +1,573 @@ +//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap, sunburst and the +//! boxes of a box and whisker chart (waterfall bars are drawn by [`crate::chart`], histograms as +//! the column chart of their bins). + +use deckcraft_color::Rgba; +use deckcraft_model::chart::{BoxStats, Chart, ChartType, TreeNode}; +use deckcraft_model::resolve::Ctx; +use deckcraft_model::text::{Align, Anchor}; +use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; +use vello_cpu::RenderContext; + +use crate::chart::{label, series_color}; +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 = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); + if vals.iter().all(|v| *v <= 0.0) { + return; + } + let value_label = |v: f64| c.data_label(s, v); + let labels = c.data_labels || c.kind == ChartType::Funnel; + match c.kind { + ChartType::Funnel => funnel(ctx, rctx, c, m, area, base, text_col, &vals, &value_label), + ChartType::Treemap if !c.category_levels.is_empty() => tree_treemap(ctx, rctx, c, m, area, base, labels.then_some(&value_label)), + ChartType::Treemap => treemap(ctx, rctx, c, m, area, base, &vals, labels.then_some(&value_label)), + _ if !c.category_levels.is_empty() => tree_sunburst(ctx, rctx, c, m, area, base), + _ => sunburst(ctx, rctx, c, m, area, base, &vals), + } +} + +/// Bars centred on one axis, as wide as their value, widest first as given: category labels on +/// the left, values inside. +#[allow(clippy::too_many_arguments)] +fn funnel( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + area: Rect, + base: f64, + text_col: Rgba, + vals: &[f64], + value_label: &dyn Fn(f64) -> String, +) { + let n = vals.len().max(1); + let gutter = c.categories.iter().map(|n| n.chars().count()).max().unwrap_or(0) as f64 * base * 0.55 + base; + let plot = Rect::new(area.x0 + gutter.min(area.width() * 0.4), area.y0, area.x1, area.y1); + let max = vals.iter().copied().fold(0.0, f64::max).max(1e-9); + let band = plot.height() / n as f64; + let cx = plot.center().x; + for (i, v) in vals.iter().enumerate() { + let w = v / max * plot.width(); + let y = plot.y0 + band * i as f64; + let r = Rect::new(cx - w / 2.0, y + band * 0.05, cx + w / 2.0, y + band * 0.95); + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, 0), 1.0)); + ctx.fill_rect(&r); + if let Some(name) = c.categories.get(i) { + label(ctx, rctx, m, name, Rect::new(area.x0, y, plot.x0 - base * 0.4, y + band), base, Align::Right, Anchor::Middle, text_col, false); + } + label(ctx, rctx, m, &value_label(*v), Rect::new(cx - 60.0, y, cx + 60.0, y + band), base, Align::Center, Anchor::Middle, Rgba::WHITE, false); + } +} + +/// 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 { + let total: f64 = vals.iter().sum(); + let mut out = vec![Rect::ZERO; vals.len()]; + if total <= 0.0 || r.area() <= 0.0 { + return out; + } + let scale = r.area() / total; + let mut order: Vec = (0..vals.len()).filter(|&i| vals.get(i).is_some_and(|v| *v > 0.0)).collect(); + order.sort_by(|a, b| vals[*b].total_cmp(&vals[*a])); + let mut free = r; + let mut i = 0; + while i < order.len() { + let short = free.width().min(free.height()); + let worst = |row: &[usize]| { + let areas: Vec = row.iter().map(|&k| vals[k] * scale).collect(); + let sum: f64 = areas.iter().sum(); + let (mx, mn) = (areas.iter().copied().fold(0.0, f64::max), areas.iter().copied().fold(f64::MAX, f64::min)); + ((short * short * mx) / (sum * sum)).max((sum * sum) / (short * short * mn)) + }; + let mut end = i + 1; + while end < order.len() && worst(&order[i..=end]) <= worst(&order[i..end]) { + end += 1; + } + let row = &order[i..end]; + let sum: f64 = row.iter().map(|&k| vals[k] * scale).sum(); + let thick = sum / short; + let mut at = 0.0; + for &k in row { + let len = vals[k] * scale / thick; + out[k] = if free.width() >= free.height() { + Rect::new(free.x0, free.y0 + at, free.x0 + thick, free.y0 + at + len) + } else { + Rect::new(free.x0 + at, free.y0, free.x0 + at + len, free.y0 + thick) + }; + at += len; + } + free = if free.width() >= free.height() { + Rect::new(free.x0 + thick, free.y0, free.x1, free.y1) + } else { + Rect::new(free.x0, free.y0 + thick, free.x1, free.y1) + }; + i = end; + } + out +} + +#[allow(clippy::too_many_arguments)] +fn treemap( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + area: Rect, + base: f64, + vals: &[f64], + value_label: Option<&dyn Fn(f64) -> String>, +) { + for (i, t) in squarify(vals, area).into_iter().enumerate() { + if t.area() <= 0.0 { + continue; + } + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, i), 1.0)); + ctx.fill_rect(&t); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.5)); + ctx.stroke_path(&t.to_path(0.1)); + let mut text = c.categories.get(i).cloned().unwrap_or_default(); + if let (Some(f), Some(v)) = (value_label, vals.get(i)) { + text = format!("{text}\n{}", f(*v)); + } + let inner = t.inset(-base * 0.3); + if inner.width() > base && inner.height() > base { + label(ctx, rctx, m, &text, inner, base, Align::Left, Anchor::Top, Rgba::WHITE, false); + } + } +} + +/// One ring of the innermost level, with the centre left open. +fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, vals: &[f64]) { + let total: f64 = vals.iter().sum(); + let r = area.width().min(area.height()) / 2.0 * 0.95; + let hole = r * 0.35; + let cen = area.center(); + let mut a0 = -std::f64::consts::FRAC_PI_2; + for (i, v) in vals.iter().enumerate() { + let sweep = v / total * std::f64::consts::TAU; + if sweep <= 0.0 { + continue; + } + let mut p = BezPath::new(); + p.move_to(Point::new(cen.x + hole * a0.cos(), cen.y + hole * a0.sin())); + p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); + p.extend(kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0).append_iter(0.1)); + let a1 = a0 + sweep; + p.line_to(Point::new(cen.x + hole * a1.cos(), cen.y + hole * a1.sin())); + p.extend(kurbo::Arc::new(cen, (hole, hole), a1, -sweep, 0.0).append_iter(0.1)); + p.close_path(); + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, i), 1.0)); + ctx.fill_path(&p); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.5)); + ctx.stroke_path(&p); + if let Some(name) = c.categories.get(i).filter(|_| sweep > 0.25) { + let mid = a0 + sweep / 2.0; + let rr = (r + hole) / 2.0; + let lp = Point::new(cen.x + rr * mid.cos(), cen.y + rr * mid.sin()); + label( + ctx, + rctx, + m, + name, + Rect::new(lp.x - 50.0, lp.y - base, lp.x + 50.0, lp.y + base), + base, + Align::Center, + Anchor::Middle, + Rgba::WHITE, + false, + ); + } + a0 = a1; + } +} + +/// `col` mixed toward white by `k` (0..1): deeper hierarchy levels are lighter. +fn lighten(col: Rgba, k: f64) -> Rgba { + let k = k.clamp(0.0, 0.8); + let f = |v: u8| (f64::from(v) + (255.0 - f64::from(v)) * k).round() as u8; + Rgba::rgb(f(col.r), f(col.g), f(col.b)) +} + +/// A multi-level treemap: top-level groups squarified by their totals, each with its label in +/// a banner along the top, and their children squarified inside, down to the leaves. A group +/// and everything in it take the group's colour. +#[allow(clippy::too_many_arguments)] +fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, value_label: Option<&dyn Fn(f64) -> String>) { + let tree = c.category_tree(); + #[allow(clippy::too_many_arguments)] + fn place( + ctx: &mut RenderContext, + rctx: &Ctx, + m: Affine, + nodes: &[TreeNode], + r: Rect, + base: f64, + col: &dyn Fn(usize) -> Rgba, + value_label: Option<&dyn Fn(f64) -> String>, + depth: usize, + ) { + let vals: Vec = nodes.iter().map(|n| n.value).collect(); + for (i, (n, t)) in nodes.iter().zip(squarify(&vals, r)).enumerate() { + if t.area() <= 0.0 { + continue; + } + let fill = col(i); + if n.children.is_empty() { + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_rect(&t); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + ctx.stroke_path(&t.to_path(0.1)); + let mut text = n.name.clone(); + if let Some(f) = value_label { + text = format!("{text}\n{}", f(n.value)); + } + let inner = t.inset(-base * 0.3); + if inner.width() > base && inner.height() > base { + label(ctx, rctx, m, &text, inner, base * 0.9, Align::Left, Anchor::Top, Rgba::WHITE, false); + } + continue; + } + // A group: its banner, then its children below it. + let banner = (base * 1.5).min(t.height() * 0.3); + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_rect(&t); + if t.width() > base * 2.0 && banner > base * 0.8 { + let head = Rect::new(t.x0 + base * 0.3, t.y0, t.x1 - base * 0.3, t.y0 + banner); + label(ctx, rctx, m, &n.name, head, base, Align::Left, Anchor::Middle, Rgba::WHITE, true); + } + let inner = Rect::new(t.x0 + 1.0, t.y0 + banner, t.x1 - 1.0, t.y1 - 1.0); + let k = lighten(fill, 0.12 * (depth + 1) as f64); + place(ctx, rctx, m, &n.children, inner, base, &|_| k, value_label, depth + 1); + ctx.set_transform(m); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(2.0)); + ctx.stroke_path(&t.to_path(0.1)); + } + } + place(ctx, rctx, m, &tree, area, base, &|i| series_color(rctx, c, i), value_label, 0); +} + +/// A multi-level sunburst: the top level as the inner ring, each deeper level in the next ring +/// out within its parent's angle, coloured by top-level group and lighter further out. +fn tree_sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64) { + let tree = c.category_tree(); + fn depth(n: &[TreeNode]) -> usize { + n.iter().map(|c| 1 + depth(&c.children)).max().unwrap_or(0) + } + let levels = depth(&tree).max(1); + let total: f64 = tree.iter().map(|n| n.value).sum(); + if total <= 0.0 { + return; + } + let r = area.width().min(area.height()) / 2.0 * 0.95; + let hole = r * 0.2; + let ring = (r - hole) / levels as f64; + let cen = area.center(); + #[allow(clippy::too_many_arguments)] + fn ring_of( + ctx: &mut RenderContext, + rctx: &Ctx, + m: Affine, + nodes: &[TreeNode], + a0: f64, + span: f64, + d: usize, + geo: (Point, f64, f64, f64), + base: f64, + col: &dyn Fn(usize) -> Rgba, + ) { + let (cen, hole, ring, total) = geo; + let sum: f64 = nodes.iter().map(|n| n.value).sum(); + if sum <= 0.0 { + return; + } + let (ri, ro) = (hole + ring * d as f64, hole + ring * (d + 1) as f64); + let mut a = a0; + for (i, n) in nodes.iter().enumerate() { + let sweep = n.value / sum * span; + if sweep <= 0.0 { + continue; + } + let fill = col(i); + let mut p = BezPath::new(); + p.move_to(Point::new(cen.x + ri * a.cos(), cen.y + ri * a.sin())); + p.line_to(Point::new(cen.x + ro * a.cos(), cen.y + ro * a.sin())); + p.extend(kurbo::Arc::new(cen, (ro, ro), a, sweep, 0.0).append_iter(0.1)); + let a1 = a + sweep; + p.line_to(Point::new(cen.x + ri * a1.cos(), cen.y + ri * a1.sin())); + p.extend(kurbo::Arc::new(cen, (ri, ri), a1, -sweep, 0.0).append_iter(0.1)); + p.close_path(); + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_path(&p); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.5)); + ctx.stroke_path(&p); + // Labels where the segment has room for them. + let mid = a + sweep / 2.0; + let rr = (ri + ro) / 2.0; + if sweep * rr > base * 2.5 && !n.name.is_empty() { + let lp = Point::new(cen.x + rr * mid.cos(), cen.y + rr * mid.sin()); + let half = (ring * 0.48).max(base); + label( + ctx, + rctx, + m, + &n.name, + Rect::new(lp.x - half, lp.y - base, lp.x + half, lp.y + base), + base * 0.9, + Align::Center, + Anchor::Middle, + Rgba::WHITE, + false, + ); + } + if !n.children.is_empty() { + let k = lighten(fill, 0.15); + ring_of(ctx, rctx, m, &n.children, a, sweep, d + 1, (cen, hole, ring, total), base, &|_| k); + } + a = a1; + } + } + ring_of(ctx, rctx, m, &tree, -std::f64::consts::FRAC_PI_2, std::f64::consts::TAU, 0, (cen, hole, ring, total), base, &|i| { + series_color(rctx, c, i) + }); +} + +/// A box chart's summaries: per series, per group. +pub struct Boxes { + pub stats: Vec>>, +} + +/// A box and whisker chart as the frame the bar renderer draws (value axis, gridlines, one +/// category per group, legend by series), and the boxes to draw in it. The frame's values are +/// each group's extremes, so the value axis covers every whisker and outlier. +pub fn box_view(c: &Chart) -> (Chart, Boxes) { + let (groups, stats) = c.box_groups(); + let mut v = c.clone(); + v.kind = ChartType::Column; + v.categories = groups; + v.raw = None; + let ext = |b: &BoxStats, hi: bool| { + let o = b.outliers.iter().copied(); + if hi { o.fold(b.high, f64::max) } else { o.fold(b.low, f64::min) } + }; + let lowest = stats.iter().flatten().flatten().map(|b| ext(b, false)).fold(f64::INFINITY, f64::min); + for (s, st) in v.series.iter_mut().zip(&stats) { + s.values = st.iter().map(|b| b.as_ref().map(|b| ext(b, true))).collect(); + } + if lowest.is_finite() && lowest < 0.0 && v.y_min.is_none() { + // The frame's values are the highs; below zero the axis must reach the lowest point too. + let mag = 10f64.powf(lowest.abs().log10().floor()); + v.y_min = Some((lowest / mag).floor() * mag); + } + (v, Boxes { stats }) +} + +/// Boxes from the first to the third quartile with the median line and a mean marker, whiskers +/// to the furthest points within 1.5 IQR, and outliers as dots; series side by side in each +/// group's band. +#[allow(clippy::too_many_arguments)] +pub fn draw_boxes( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + b: &Boxes, + m: Affine, + plot: Rect, + band: f64, + horiz: bool, + to_v: &dyn Fn(f64) -> f64, +) { + let ns = b.stats.len().max(1); + let group = band / (1.0 + c.gap_width.clamp(0.0, 5.0)); + let w = group / ns as f64; + let line = Rgba::rgb(0x40, 0x40, 0x40); + for (si, per) in b.stats.iter().enumerate() { + let fill = series_color(rctx, c, si); + for (gi, st) in per.iter().enumerate() { + let Some(st) = st else { continue }; + let c0 = if horiz { plot.y0 } else { plot.x0 } + band * gi as f64 + (band - group) / 2.0 + w * si as f64; + let (a, z) = (c0 + w * 0.1, c0 + w * 0.9); + let mid = (a + z) / 2.0; + // Along the category axis `a..z`, along the value axis `to_v`. + let rect = |p: f64, q: f64, u: f64, v: f64| if horiz { Rect::new(u.min(v), p, u.max(v), q) } else { Rect::new(p, u.min(v), q, u.max(v)) }; + let pt = |along: f64, val: f64| if horiz { Point::new(val, along) } else { Point::new(along, val) }; + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + let bx = rect(a, z, to_v(st.q1), to_v(st.q3)); + ctx.fill_rect(&bx); + let mut p = BezPath::new(); + // Whiskers with caps, and the median. + for (from, to) in [(st.q3, st.high), (st.q1, st.low)] { + p.move_to(pt(mid, to_v(from))); + p.line_to(pt(mid, to_v(to))); + p.move_to(pt(mid - w * 0.2, to_v(to))); + p.line_to(pt(mid + w * 0.2, to_v(to))); + } + p.move_to(pt(a, to_v(st.median))); + p.line_to(pt(z, to_v(st.median))); + ctx.set_paint(color(line, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.25)); + ctx.stroke_path(&p); + ctx.stroke_path(&bx.to_path(0.1)); + // The mean as an x. + let (mx, r) = (pt(mid, to_v(st.mean)), (w * 0.08).clamp(1.5, 5.0)); + let mut x = BezPath::new(); + x.move_to((mx.x - r, mx.y - r)); + x.line_to((mx.x + r, mx.y + r)); + x.move_to((mx.x - r, mx.y + r)); + x.line_to((mx.x + r, mx.y - r)); + ctx.stroke_path(&x); + for o in &st.outliers { + let q = pt(mid, to_v(*o)); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_path(&kurbo::Circle::new(q, r).to_path(0.1)); + ctx.set_paint(color(line, 1.0)); + ctx.stroke_path(&kurbo::Circle::new(q, r).to_path(0.1)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn squarify_tiles_the_area_in_proportion() { + let vals = [6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0]; + let r = Rect::new(0.0, 0.0, 600.0, 400.0); + let tiles = squarify(&vals, r); + let total: f64 = vals.iter().sum(); + for (t, v) in tiles.iter().zip(vals) { + assert!((t.area() - v / total * r.area()).abs() < 1e-6, "{t:?} for {v}"); + assert!(t.x0 >= -1e-9 && t.y0 >= -1e-9 && t.x1 <= 600.0 + 1e-6 && t.y1 <= 400.0 + 1e-6, "{t:?}"); + // Squarified: no sliver tiles. + let aspect = t.width().max(t.height()) / t.width().min(t.height()); + assert!(aspect < 4.0, "{t:?}"); + } + for (i, a) in tiles.iter().enumerate() { + for b in &tiles[i + 1..] { + assert!(a.intersect(*b).area() < 1e-6, "{a:?} overlaps {b:?}"); + } + } + assert!(squarify(&[0.0, 0.0], r).iter().all(|t| t.area() == 0.0)); + } + + #[test] + fn waterfall_bars_float_and_totals_stand() { + let c = Chart { + kind: ChartType::Waterfall, + subtotals: vec![3], + series: vec![deckcraft_model::chart::Series { values: vec![Some(100.0), Some(40.0), Some(-25.0), Some(115.0)], ..Default::default() }], + ..Default::default() + }; + assert_eq!(c.waterfall_bars(), [(0.0, 100.0), (100.0, 140.0), (140.0, 115.0), (0.0, 115.0)]); + assert_eq!(c.value_range(), (0.0, 140.0)); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7f1b099d..df7e43c2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,6 +10,7 @@ mod bevel; mod camera; mod chart; +mod chartex; mod colr; mod extrude; mod light; @@ -25,6 +26,7 @@ mod pattern; mod pathgrad; mod placed; +mod radar; mod ramp; mod table; mod transition; diff --git a/crates/render/src/radar.rs b/crates/render/src/radar.rs new file mode 100644 index 00000000..3416c7ce --- /dev/null +++ b/crates/render/src/radar.rs @@ -0,0 +1,187 @@ +//! Radar charts: one spoke per category, the value axis running up the first spoke, a polygon +//! of gridlines per major unit and each series a closed polygon (filled for a filled radar). + +use deckcraft_color::Rgba; +use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::resolve::Ctx; +use deckcraft_model::text::{Align, Anchor}; +use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; +use vello_cpu::RenderContext; + +use crate::chart::{label, series_fill, series_line}; +use crate::color; + +/// The point at `frac` of the radius along spoke `i` of `n` (the first spoke points up). +fn spoke(cen: Point, r: f64, i: usize, n: usize, frac: f64) -> Point { + let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * i as f64 / n.max(1) as f64; + Point::new(cen.x + r * frac * a.cos(), cen.y + r * frac * a.sin()) +} + +pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let n = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); + if n < 3 { + // Fewer than three spokes make no polygon; PowerPoint draws a line, we draw nothing. + return; + } + let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); + // Room for the category labels around the outside. + let r = (area.width().min(area.height()) / 2.0 - base * 1.9).max(base); + let cen = area.center(); + let (min, max, step) = c.axis_scale(false, 5).unwrap_or((0.0, 1.0, 0.2)); + let span = (max - min).max(1e-9); + let ticks = (((max - min) / step.max(1e-12)).round() as i64).clamp(1, 20); + ctx.set_transform(m); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.set_paint(color(grid_col, 1.0)); + if c.gridlines { + for k in 1..=ticks { + let frac = k as f64 / ticks as f64; + let mut p = BezPath::new(); + for i in 0..n { + let q = spoke(cen, r, i, n, frac); + if i == 0 { p.move_to(q) } else { p.line_to(q) } + } + p.close_path(); + ctx.stroke_path(&p); + } + } + for i in 0..n { + let mut p = BezPath::new(); + p.move_to(cen); + p.line_to(spoke(cen, r, i, n, 1.0)); + ctx.set_transform(m); + ctx.set_paint(color(grid_col, 1.0)); + ctx.stroke_path(&p); + if let Some(name) = c.categories.get(i) { + let q = spoke(cen, r + base * 1.3, i, n, 1.0); + let w = name.chars().count() as f64 * base * 0.55 + base; + let align = if (q.x - cen.x).abs() < base { + Align::Center + } else if q.x > cen.x { + Align::Left + } else { + Align::Right + }; + let x0 = match align { + Align::Left => q.x - base * 0.3, + Align::Right => q.x - w + base * 0.3, + _ => q.x - w / 2.0, + }; + label(ctx, rctx, m, name, Rect::new(x0, q.y - base * 0.7, x0 + w, q.y + base * 0.7), base, align, Anchor::Middle, text_col, false); + } + } + // Value labels up the first spoke. + if !c.y_hidden { + for k in 0..=ticks { + let v = min + step * k as f64; + let q = spoke(cen, r, 0, n, k as f64 / ticks as f64); + let t = c.axis_label(false, v); + let w = t.chars().count() as f64 * base * 0.55 + base * 0.4; + label( + ctx, + rctx, + m, + &t, + Rect::new(q.x - w - base * 0.3, q.y - base * 0.6, q.x - base * 0.3, q.y + base * 0.6), + base * 0.9, + Align::Right, + Anchor::Middle, + text_col, + false, + ); + } + } + let filled = c.kind == ChartType::FilledRadar; + for (si, s) in c.series.iter().enumerate() { + let pts: Vec = (0..n) + .map(|i| { + let v = s.values.get(i).copied().flatten().unwrap_or(min); + spoke(cen, r, i, n, ((v - min) / span).clamp(0.0, 1.2)) + }) + .collect(); + let mut p = BezPath::new(); + for (i, q) in pts.iter().enumerate() { + if i == 0 { p.move_to(*q) } else { p.line_to(*q) } + } + p.close_path(); + ctx.set_transform(m); + if filled { + ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 0.75)); + ctx.fill_path(&p); + } else { + let col = series_line(rctx, c, si, s); + let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round)); + ctx.stroke_path(&p); + if s.marker.is_some() { + for q in &pts { + ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); + } + } + } + if c.data_labels || s.data_labels == Some(true) { + for (i, q) in pts.iter().enumerate() { + if let Some(v) = s.values.get(i).copied().flatten() { + let t = c.data_label(s, v); + label( + ctx, + rctx, + m, + &t, + Rect::new(q.x - 40.0, q.y - base * 1.4, q.x + 40.0, q.y - base * 0.2), + base * 0.9, + Align::Center, + Anchor::Bottom, + text_col, + false, + ); + } + } + } + } +} + +/// A stock chart's marks at one category: the high-low line, then the up/down bar between the +/// first (open) and last (close) series, else a dash at the close. +#[allow(clippy::too_many_arguments)] +pub fn stock_marks(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, x: f64, band: f64, i: usize, to_y: &dyn Fn(f64) -> f64) { + let vals: Vec = c.series.iter().filter_map(|s| s.values.get(i).copied().flatten()).collect(); + if vals.len() < 2 { + return; + } + let hi = vals.iter().copied().fold(f64::MIN, f64::max); + let lo = vals.iter().copied().fold(f64::MAX, f64::min); + let ink = Rgba::rgb(0x40, 0x40, 0x40); + ctx.set_transform(m); + ctx.set_paint(color(ink, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + let mut p = BezPath::new(); + p.move_to((x, to_y(hi))); + p.line_to((x, to_y(lo))); + ctx.stroke_path(&p); + let open = c.series.first().and_then(|s| s.values.get(i).copied().flatten()); + let close = c.series.last().and_then(|s| s.values.get(i).copied().flatten()); + if c.up_down_bars && c.series.len() >= 4 { + let (Some(o), Some(cl)) = (open, close) else { return }; + let w = band * 0.4; + let (y0, y1) = (to_y(o.max(cl)), to_y(o.min(cl))); + let r = Rect::new(x - w / 2.0, y0, x + w / 2.0, y1.max(y0 + 1.0)); + // PowerPoint's default: white when the price rose, dark when it fell. + ctx.set_paint(color(if cl >= o { Rgba::WHITE } else { ink }, 1.0)); + ctx.fill_rect(&r); + ctx.set_paint(color(ink, 1.0)); + ctx.stroke_path(&r.to_path(0.1)); + return; + } + if let Some(cl) = close { + let col = c.series.last().map(|s| series_line(rctx, c, c.series.len() - 1, s)).unwrap_or(ink); + let y = to_y(cl); + let mut d = BezPath::new(); + d.move_to((x - band * 0.12, y)); + d.line_to((x + band * 0.12, y)); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(2.5)); + ctx.stroke_path(&d); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 39d65bd7..d7aec2a6 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1,5 +1,5 @@ use super::*; -use deckcraft_model::chart::ChartType; +use deckcraft_model::chart::{ChartType, Trendline, TrendlineType}; use deckcraft_model::style::ColorRef; use deckcraft_model::text::TextBody; use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm}; @@ -155,6 +155,110 @@ fn combo_chart_draws_columns_and_a_line_on_its_own_axis() { assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}"); } +fn trend_chart(title: &str, kind: ChartType, ys: &[f64], trend: Trendline, y_max: f64) -> Chart { + let mut c = Chart::sample(kind); + c.title = Some(title.into()); + c.legend = None; + c.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()]; + c.series.truncate(1); + c.series[0].name = "Data".into(); + c.series[0].values = ys.iter().copied().map(Some).collect(); + c.y_min = Some(0.0); + c.y_max = Some(y_max); + c.series[0].trendlines = vec![trend]; + c +} + +fn trend_line(kind: TrendlineType) -> Trendline { + Trendline { kind, disp_eq: true, disp_r_sqr: true, ..Default::default() } +} + +/// Six charts, one trendline type each. `draw` drops the trendlines. `forecast` extends the linear fit. +fn trend_gallery(draw: bool, forecast: bool) -> Image { + let mut linear = trend_line(TrendlineType::Linear); + if forecast { + linear.forward = Some(1.0); + linear.backward = Some(0.5); + } + let mut poly = trend_line(TrendlineType::Poly); + poly.order = Some(2); + let mut ma = trend_line(TrendlineType::MovingAvg); + ma.period = Some(2); + let mut charts = vec![ + trend_chart("Linear", ChartType::Column, &[3.0, 5.0, 7.0, 9.0], linear, 12.0), + trend_chart("Exponential", ChartType::LineMarkers, &[1.0, 2.0, 4.0, 8.0], trend_line(TrendlineType::Exp), 10.0), + trend_chart( + "Logarithmic", + ChartType::LineMarkers, + &[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()], + trend_line(TrendlineType::Log), + 2.0, + ), + trend_chart("Polynomial", ChartType::LineMarkers, &[1.0, 4.0, 9.0, 16.0], poly, 18.0), + trend_chart("Power", ChartType::LineMarkers, &[2.0, 2.0 * 2.0_f64.sqrt(), 2.0 * 3.0_f64.sqrt(), 4.0], trend_line(TrendlineType::Power), 6.0), + trend_chart("Moving average", ChartType::LineMarkers, &[2.0, 8.0, 4.0, 10.0], ma, 12.0), + ]; + if !draw { + for c in &mut charts { + c.series[0].trendlines.clear(); + } + } + let shapes = charts + .into_iter() + .enumerate() + .map(|(i, c)| Shape { + id: ShapeId(800 + i as u32), + xfrm: Some(Xfrm::new(12.0 + (i % 3) as f64 * 316.0, 12.0 + (i / 3) as f64 * 264.0, 304.0, 252.0)), + kind: ShapeKind::Chart(Box::new(c)), + ..Default::default() + }) + .collect(); + render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +#[test] +fn trendlines_draw_through_the_points_with_a_label() { + let on = trend_gallery(true, false); + let off = trend_gallery(false, false); + assert_eq!((on.width, on.height), (960, 540)); + let dark = on + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(off.pixels.as_chunks::<4>().0) + .filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20) + .count(); + assert!(dark > 40, "dashed trendline ink {dark}"); + // A scatter fit uses the real x values and still paints the dash. + let mut scatter = trend_chart("Scatter", ChartType::Scatter, &[2.0, 4.0, 6.0, 8.0], trend_line(TrendlineType::Linear), 10.0); + scatter.series[0].x_values = (1..=4).map(|x| Some(x as f64)).collect(); + let sh = Shape { + id: ShapeId(810), + xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), + kind: ShapeKind::Chart(Box::new(scatter.clone())), + ..Default::default() + }; + let with = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + scatter.series[0].trendlines.clear(); + let sh = + Shape { id: ShapeId(810), xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), kind: ShapeKind::Chart(Box::new(scatter)), ..Default::default() }; + let without = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let scatter_dark = with + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(without.pixels.as_chunks::<4>().0) + .filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20) + .count(); + assert!(scatter_dark > 20, "scatter trendline ink {scatter_dark}"); + if std::env::var_os("DECKCRAFT_TREND_SHOT").is_some() { + let png = trend_gallery(true, true).to_png(); + std::fs::write("/tmp/deckcraft-trendlines.png", &png).expect("write trendline screenshot"); + } +} + #[test] fn chart_axis_titles_are_drawn() { let draw = |titles: (Option, Option)| { @@ -177,6 +281,69 @@ fn chart_axis_titles_are_drawn() { assert!(ink(&titled, bottom) > ink(&bare, bottom) + 30, "category axis title"); } +#[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. + let mut c = Chart::sample(ChartType::Column); + c.title = None; + c.legend = None; + c.series.truncate(1); + c.categories = vec!["A".into(), "B".into(), "C".into()]; + c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)]; + c.series[0].point_fills = vec![(1, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))]; + let sh = Shape { id: ShapeId(621), 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() }); + let red = |x: u32| (300..500).any(|y| img.pixel(x, y)[..3] == [255, 0, 0]); + assert!(red(480), "the middle column takes its point's fill"); + assert!(!red(200) && !red(760), "the others keep the series colour"); + + // Two series, gap 150 %, no overlap: each bar is a band / 3.5 wide (PowerPoint's and + // LibreOffice's geometry), not band / 2.5 split in two. + let mut c = Chart::sample(ChartType::Column); + c.title = None; + c.legend = None; + c.series.truncate(2); + c.categories = vec!["A".into()]; + c.series[0].values = vec![Some(10.0)]; + c.series[1].values = vec![Some(10.0)]; + c.series[0].fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); + (c.gap_width, c.overlap) = (1.5, 0.0); + let sh = Shape { id: ShapeId(622), 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() }); + let y = (0..540).rev().find(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).unwrap() - 5; + let reds = (0..960).filter(|x| img.pixel(*x, y)[..3] == [255, 0, 0]).count() as f64; + // The plot is ~850 px wide with one category: a bar is ~240 px (it was ~170 when the band + // was split as 1 + gap and then shared between the series). + assert!((200.0..280.0).contains(&reds), "bar {reds} px"); + + // A smoothed line through a peak overshoots no point but leaves the straight chord. + let pts = [kurbo::Point::new(0.0, 0.0), kurbo::Point::new(10.0, 10.0), kurbo::Point::new(20.0, 0.0)]; + let p = crate::chart::smooth_path(&pts); + assert_eq!(p.elements().len(), 3, "a cubic per segment"); + let mid = kurbo::ParamCurve::eval(&p.segments().next().unwrap(), 0.5); + assert!(mid.y > mid.x, "the first half bows towards the peak instead of following the chord: {mid:?}"); +} + +#[test] +fn horizontal_bars_list_the_first_category_at_the_bottom_unless_reversed() { + let draw = |reversed: bool| { + let mut c = Chart::sample(ChartType::Bar); + c.title = None; + c.legend = None; + c.series.truncate(1); + c.categories = vec!["A".into(), "B".into(), "C".into()]; + c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)]; + c.series[0].point_fills = vec![(0, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))]; + c.cat_reversed = reversed; + let sh = Shape { id: ShapeId(623), 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() }); + let rows: Vec = (0..540).filter(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).collect(); + (rows[0] + rows[rows.len() - 1]) / 2 + }; + assert!(draw(false) > 360, "first category at the bottom: {}", draw(false)); + assert!(draw(true) < 180, "reversed: first category at the top: {}", draw(true)); +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default(); @@ -1975,3 +2142,238 @@ fn table_rows_below_the_slide_are_not_laid_out_and_the_slide_looks_the_same() { let diff = (0..big.height).flat_map(|y| (0..big.width).map(move |x| (x, y))).filter(|&(x, y)| big.pixel(x, y) != small.pixel(x, y)).count(); assert_eq!(diff, 0); } + +#[test] +fn office_2016_charts_draw_their_own_layouts() { + let chart = |kind: ChartType, vals: &[f64], subtotals: Vec| { + let c = Chart { + kind, + title: None, + legend: None, + gridlines: false, + subtotals, + categories: (0..vals.len()).map(|i| format!("C{i}")).collect(), + series: vec![deckcraft_model::chart::Series { values: vals.iter().map(|v| Some(*v)).collect(), ..Default::default() }], + ..Default::default() + }; + let sh = Shape { id: ShapeId(621), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let ink = |img: &crate::Image, x: u32, y: u32| { + let p = img.pixel(x, y); + p[0] < 235 || p[1] < 235 || p[2] < 235 + }; + // Waterfall: the decrease floats (nothing under it near the axis), the total stands on it. + let img = chart(ChartType::Waterfall, &[100.0, 40.0, -25.0, 115.0], vec![3]); + let plot_bottom = (300..530).rev().find(|&y| ink(&img, 120, y)).unwrap_or(500); + let band = |i: f64| (80.0 + (900.0 - 80.0) * (i + 0.5) / 4.0) as u32; + assert!(!ink(&img, band(2.0), plot_bottom - 20), "the decrease bar reaches the axis"); + assert!(ink(&img, band(3.0), plot_bottom - 20), "the total bar doesn't stand on the axis"); + assert!(ink(&img, band(1.0), 100), "the increase bar is missing"); + // Funnel, treemap and sunburst fill the middle of the chart. + for kind in [ChartType::Funnel, ChartType::Treemap, ChartType::Sunburst] { + let img = chart(kind, &[50.0, 30.0, 20.0], vec![]); + let covered = (0..540).step_by(10).flat_map(|y| (0..960).step_by(10).map(move |x| (x, y))).filter(|&(x, y)| ink(&img, x, y)).count(); + assert!(covered > 1000, "{kind:?}: {covered} samples inked"); + } +} + +/// Histograms draw touching columns of their bins, Pareto charts add the cumulative line, and +/// box and whisker charts draw a box per group and series. +#[test] +fn histogram_pareto_and_box_charts_draw() { + let shot = |c: Chart| { + let sh = Shape { id: ShapeId(622), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let inked = |img: &crate::Image| { + (0..540) + .step_by(6) + .flat_map(|y| (0..960).step_by(6).map(move |x| (x, y))) + .filter(|&(x, y)| { + let p = img.pixel(x, y); + p[0] < 200 || p[1] < 200 || p[2] < 200 + }) + .count() + }; + let mut empty = Chart::sample(ChartType::Histogram); + empty.series[0].values.clear(); + let blank = inked(&shot(empty)); + for kind in [ChartType::Histogram, ChartType::Pareto, ChartType::BoxWhisker] { + let img = shot(Chart::sample(kind)); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/{kind:?}.png"), img.to_png()); + } + let n = inked(&img); + assert!(n > blank + 400, "{kind:?}: {n} inked samples (blank {blank})"); + } +} + +/// Multi-level treemaps and sunbursts draw their groups: the treemap's group banners and the +/// sunburst's inner ring of groups, coloured by top-level group. +#[test] +fn multilevel_treemap_and_sunburst_draw_their_groups() { + for kind in [ChartType::Treemap, ChartType::Sunburst] { + let c = Chart::sample(kind); + let sh = Shape { id: ShapeId(623), 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}/{kind:?}-levels.png"), img.to_png()); + } + // Distinct colours: one hue per top-level group plus lighter children. + let mut seen: Vec<[u8; 3]> = Vec::new(); + for y in (60..520).step_by(8) { + for x in (40..920).step_by(8) { + 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() >= 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()); +} + +/// Radar charts are polar, stock charts draw high-low lines (and up/down bars), and a combo +/// chart draws its line series over its columns. +#[test] +fn radar_stock_and_combo_draw_their_own_layouts() { + let mut ohlc = Chart::sample(ChartType::Stock); + ohlc.series.insert( + 0, + deckcraft_model::chart::Series { + name: "Open".into(), + values: [44.0, 25.0, 38.0, 50.0, 35.0].iter().map(|v| Some(*v)).collect(), + ..Default::default() + }, + ); + ohlc.up_down_bars = true; + let charts = [ + ("Radar", Chart::sample(ChartType::Radar)), + ("FilledRadar", Chart::sample(ChartType::FilledRadar)), + ("Stock", Chart::sample(ChartType::Stock)), + ("StockOHLC", ohlc), + ("Combo", Chart::sample(ChartType::Combo)), + ]; + for (name, c) in charts { + 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() }); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/{name}.png"), img.to_png()); + } + let ink = |x: u32, y: u32| img.pixel(x, y)[..3] != [255, 255, 255]; + match name { + // The radar's top spoke runs straight up from the centre; a line chart has nothing there. + "Radar" | "FilledRadar" => assert!((150..260).any(|y| ink(480, y)), "{name}"), + // No series lines: well right of the first category there is only gridline grey. + "Stock" | "StockOHLC" => { + let coloured = (60..480).step_by(2).flat_map(|y| (40..920).step_by(4).map(move |x| (x, y))).any(|(x, y)| { + let p = img.pixel(x, y); + p[2] > p[0].saturating_add(60) + }); + assert!(!coloured || name == "Stock", "{name}: series drawn as lines"); + } + _ => {} + } + } +} + +#[test] +fn charts_in_degenerate_frames_draw_without_panicking() { + // A chart frame squashed to no width (cx="0" in a generated file) made the value labels' + // gutter clamp panic (its upper bound, a share of the width, fell below its lower bound). + // Every chart type and legend position must survive any frame size. + let p = Presentation::default(); + let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap(); + let kinds = [ + ChartType::Column, + ChartType::StackedColumn, + ChartType::PercentColumn, + ChartType::Bar, + ChartType::StackedBar, + ChartType::PercentBar, + ChartType::Line, + ChartType::LineMarkers, + ChartType::StackedLine, + ChartType::Area, + ChartType::StackedArea, + ChartType::Pie, + ChartType::Doughnut, + ChartType::Scatter, + ChartType::Bubble, + ChartType::Radar, + ChartType::FilledRadar, + ChartType::Stock, + ChartType::Surface, + ChartType::Histogram, + ChartType::Pareto, + ChartType::BoxWhisker, + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Treemap, + ChartType::Sunburst, + ChartType::Combo, + ]; + let mut ctx = vello_cpu::RenderContext::new(64, 64); + for k in kinds { + for (w, h) in [(0.0, 403.0), (403.0, 0.0), (0.0, 0.0), (2.0, 400.0), (400.0, 2.0), (12.0, 12.0)] { + for legend in [None, Some("b"), Some("t"), Some("r"), Some("l"), Some("tr")] { + let mut c = Chart::sample(k); + c.legend = legend.map(String::from); + crate::chart::draw(&mut ctx, &rctx, &c, kurbo::Affine::IDENTITY, w, h); + } + } + } +} + +/// Renders the EMF at `$DECKCRAFT_EMF_IN` to `$DECKCRAFT_EMF_PNG` (a look-at helper; does nothing +/// without them). +#[test] +fn render_an_emf_file_to_look_at() { + let (Ok(src), Ok(out)) = (std::env::var("DECKCRAFT_EMF_IN"), std::env::var("DECKCRAFT_EMF_PNG")) else { return }; + let Ok(bytes) = std::fs::read(&src) else { return }; + let mut p = Presentation::default(); + let media = p.add_media("image1.emf", "image/x-emf", bytes); + let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; + let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(40.0, 40.0, 840.0, 400.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let _ = std::fs::write(out, img.to_png()); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 85f98652..3d624d53 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1207,8 +1207,13 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::SmartArt, "SmartArt", "app.dialog", json!({"id": "smartart"})); let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart")); egui::Popup::menu(&r).show(|ui| { - for k in deckcraft_model::chart::ChartType::MAIN { - if ui.button(k.label()).clicked() { + for k in chart_menu_types() { + if k.is_none() { + ui.separator(); + } + if let Some(k) = k + && ui.button(k.label()).clicked() + { run(app, "insert.chart", json!({"type": format!("{k:?}")})); } } @@ -2943,7 +2948,11 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true); egui::Popup::menu(&r).show(|ui| { - for k in deckcraft_model::chart::ChartType::MAIN { + for k in chart_menu_types() { + let Some(k) = k else { + ui.separator(); + continue; + }; if ui.button(k.label()).clicked() { // Replace the chart's type keeping its data. if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) { @@ -3254,6 +3263,16 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } +/// The chart types in the Insert Chart and Change Chart Type menus: the main ones, a separator +/// (`None`), then the Office 2016 types not already listed. +fn chart_menu_types() -> Vec> { + use deckcraft_model::chart::ChartType; + let mut v: Vec> = ChartType::MAIN.iter().copied().map(Some).collect(); + v.push(None); + v.extend(ChartType::OFFICE_2016.iter().copied().filter(|k| !ChartType::MAIN.contains(k)).map(Some)); + v +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 59fb7a44..5d7bf979 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -11,6 +11,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines round-trip; previously trivial: groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for tables, charts, animations, media, sections and masters; handout-master text kept; previous: trivial, groups with a small legacy child coordinate space) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a PNG or JPEG slide export records its resolution, so the default 2× picture is 144 dpi; previous: groups with a small legacy child coordinate space draw outlines at their own width, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previously minor: chart trendlines round-trip; groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -54,7 +55,7 @@ PowerPoint opens and saves; Viewer = opens only). |---|---|---|---| | PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 | | SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted | -| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances). Rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing; EMF+ records are not drawn. Legacy OLE objects (VML preview) and ActiveX controls show their pictures | +| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances). Rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ only pictures play every EMF+ drawing record (brushes incl. gradients and textures, pens, paths, curves, images, strings, transforms, clipping; see `crates/metafile/src/emfplus.rs` for the few exceptions). Legacy OLE objects (VML preview) and ActiveX controls show their pictures | | HEIC | yes (macOS) | no | | | Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` | | WMA | yes (Windows) | embedded, not playable | | @@ -93,8 +94,8 @@ the round trip even though DeckCraft doesn't model it. | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Tables with styles | yes | yes | Merges and both diagonal borders round-trip | -| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read and written; they are not drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt | -| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | +| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt | +| 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; text laid out on its `dsp:txXfrm` rectangle | as shapes | Diagram data model lost on save | | 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) | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save (DeckCraft keeps its own outline in `.deckcraft` only) | @@ -137,6 +138,7 @@ the round trip even though DeckCraft doesn't model it. | 2026-10-13 | minor | Owned corpus for comments, links and actions, custom shows, rotated groups and inherited placeholders. Paragraph `defRPr` and action sounds (including custom-show return) survive a save | | 2026-10-13 | minor | Owned corpus for text effects, body layout, theme effects, fills, bevels and connectors. A run's inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML, survive a save | | 2026-10-14 | minor | 3-D models (`.glb`) round-trip, including an alternate-content fallback picture; animation bounce end is read and written (`corpus_media.rs`) | +| 2026-10-14 | minor | Chart trendlines are drawn (linear, exponential, logarithmic, polynomial, power, moving average, forecast, equation and R²) | | 2026-10-14 | minor | Chart trendlines round-trip (`corpus_charts.rs`); merges, diagonal borders, columns, autofit, vertical text and WordArt warp are in that slice | | 2026-10-13 | minor | Add-in tags on the presentation, slides and shapes read and written | | 2026-10-13 | minor | Custom XML parts, the sensitivity label and custom properties kept on save | diff --git a/docs/gaps.md b/docs/gaps.md index 14692b3f..01d35484 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -24,6 +24,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (command palette no longer offers Export without a path, #79; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; checklist row stays P so breadth stays 79%; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; checklist letters unchanged, breadth stays 79%, dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previous: minor, chart trendlines round-trip; 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -152,8 +153,8 @@ Detail in [file-format-parity.md](file-format-parity.md). ### SVG pictures don't render - **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF, TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens, - pattern and hatch brushes, bitmaps and text; rectangle clip regions are applied, EMF+ records are - not drawn). + pattern and hatch brushes, bitmaps and text; rectangle clip regions are applied, EMF+ only + pictures play their EMF+ records). Nothing reads SVG; PowerPoint's Insert Icons stores SVG with a PNG fallback. - **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. - **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates). @@ -219,10 +220,20 @@ Detail in [file-format-parity.md](file-format-parity.md). ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other - chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & - whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are not drawn. A combo - chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and is not written back - when the chart is rebuilt. + chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto, + box & whisker) are read, drawn, inserted, edited and saved as `chartEx` (kept unchanged, else + 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 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. Radar charts are drawn polar (filled + or lines), stock charts as high-low lines with close dashes or up/down bars and saved as + `c:stockChart`, and combo charts start as columns with a line. Trendlines round-trip + (`corpus_charts.rs`) and are drawn: linear, exponential, logarithmic, polynomial, power and + moving average, with forecast and the equation and R² label. A moving average is drawn with + neither label. A combo chart's secondary value axis is read (`y2_*`, + `Series.secondary_axis`) and is not written back when the chart is rebuilt. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). @@ -409,6 +420,8 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-13 | minor | Benchmark suite (`cargo xtask bench`, `deckcraft-cli bench`): the performance gap is now frame rate and a PowerPoint comparison | | 2026-10-14 | minor | Owned corpus `corpus_media.rs`: 3-D model parts and animation bounce end now round-trip. The real-deck corpus and #70/#74/#77 stay open | | 2026-10-14 | trivial | Corpus slice: themes, masters, effects and links; text Mouse Over actions round-trip | +| 2026-10-14 | trivial | A moving average trendline is drawn without an equation or an R² label | +| 2026-10-14 | minor | Chart trendlines are drawn (six types, forecast, equation and R²). The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus gap stays open | | 2026-10-14 | minor | Chart trendlines round-trip. The real-deck corpus gap stays open; secondary-axis export and drawing trendlines stay in the charts gap | | 2026-10-14 | trivial | 3-D contours | | 2026-10-14 | trivial | 3-D light rigs and materials; extrusion sides lit per side | diff --git a/docs/roadmap.md b/docs/roadmap.md index 89f1a56f..b13af4b5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,6 +15,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (presenter timer pause/reset, clock and notes size; previous: trivial, See All Slides and zoom) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (custom show playback; previous: trivial, presenter timer) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (go to slide shows the typed number; previous: trivial, custom show playback) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (chart trendlines are drawn; the charts gap stays open for the data grid, chartex and secondary-axis export; previous: trivial, chart trendlines round-trip; the real-deck corpus stays open; core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -153,6 +154,7 @@ plan above. | 2026-10-13 | trivial | Owned PPTX corpus covers comments, links and actions, custom shows, rotated groups and inherited placeholders. Beta item 2 stays open for a larger corpus, embedded fonts and #70/#74/#77 | | 2026-10-13 | trivial | Owned PPTX corpus covers text effects, body layout, theme effects, fills, bevels and connectors. Beta item 2 stays open for a larger corpus, embedded fonts and #70/#74/#77 | | 2026-10-14 | trivial | Current focus: `corpus_media.rs` keeps 3-D models and animation bounce end; the real-deck corpus stays open | +| 2026-10-14 | trivial | Chart trendlines are drawn. The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus (item 2) stays open | | 2026-10-14 | trivial | Chart trendlines round-trip in an owned corpus slice; the real-deck corpus (item 2) stays open | | 2026-10-14 | trivial | Review workflow: previous and next comment across the deck | | 2026-10-14 | minor | After beta: six original starter decks exist; a larger template gallery remains |