mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 03:56:50 -06:00
Merge the chart and EMF+ metafile PRs integrated on land-r2 (#532 #537 #591 #594 #598 #602 #603 #638 #640 #662 #666 #669 #690 #582 #588 #633 #635)
# Conflicts: # crates/pptx/src/write/mod.rs # crates/pptx/tests/roundtrip.rs
This commit is contained in:
Generated
+1
@@ -1053,6 +1053,7 @@ dependencies = [
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -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% |
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<Value> {
|
||||
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<Value> {
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let levels: Option<Vec<Vec<String>>> = 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<Value> {
|
||||
}
|
||||
|
||||
fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// 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<Vec<usize>> =
|
||||
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<usize> = 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);
|
||||
}
|
||||
|
||||
@@ -11,3 +11,4 @@ workspace = true
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
@@ -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<Vec<[f64; 4]>>,
|
||||
/// 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<Vec<P>>,
|
||||
}
|
||||
|
||||
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<Vec<P>>) {
|
||||
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<RawTile>) {
|
||||
let dev: Vec<Op> = 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<Op> = 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) {
|
||||
|
||||
@@ -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<P> {
|
||||
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<Vec<P>> {
|
||||
let mut v: Vec<P> = 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<P> {
|
||||
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<Raw> {
|
||||
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<P>, bool)> {
|
||||
let mut subs: Vec<(Vec<P>, 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<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
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<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
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<P> = 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 }))
|
||||
}
|
||||
@@ -46,6 +46,7 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), 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() {
|
||||
|
||||
+1164
-133
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@
|
||||
mod bytes;
|
||||
mod canvas;
|
||||
mod clip;
|
||||
mod cliprect;
|
||||
mod dib;
|
||||
mod emf;
|
||||
mod emfplus;
|
||||
|
||||
@@ -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<Vec<u8>>) -> 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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<f32> = 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<u8> {
|
||||
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<Vec<u8>>| {
|
||||
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<f32> = 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);
|
||||
}
|
||||
|
||||
@@ -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<f64>,
|
||||
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<String>, Option<String>),
|
||||
/// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…).
|
||||
pub style: u32,
|
||||
@@ -258,10 +280,15 @@ pub struct Chart {
|
||||
pub y_max: Option<f64>,
|
||||
/// The value axis' major unit, when the chart fixes it.
|
||||
pub y_major: Option<f64>,
|
||||
/// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000).
|
||||
pub y_log_base: Option<f64>,
|
||||
/// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`).
|
||||
pub y_format: Option<String>,
|
||||
/// 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<f64>,
|
||||
pub y2_max: Option<f64>,
|
||||
@@ -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<String>,
|
||||
/// 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<usize>,
|
||||
/// 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<Vec<String>>,
|
||||
/// 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<u32>,
|
||||
/// A histogram's bin width (`cx:binSize`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bin_size: Option<f64>,
|
||||
/// Original chart XML part kept for round-trip of unmodelled options.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw: Option<String>,
|
||||
@@ -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<f64>,
|
||||
}
|
||||
|
||||
impl Chart {
|
||||
/// The first series' finite observations.
|
||||
fn observations(&self) -> Vec<f64> {
|
||||
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<Bin> {
|
||||
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::<f64>() / n;
|
||||
let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (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<Bin> = (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::<usize>().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<String>, Vec<Vec<Option<BoxStats>>>) {
|
||||
let mut groups: Vec<String> = 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<f64> = 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<f64>) -> Option<BoxStats> {
|
||||
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<f64>, inclusive: bool) -> Option<BoxStats> {
|
||||
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<f64> = 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::<f64>() / 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<usize>,
|
||||
pub children: Vec<TreeNode>,
|
||||
}
|
||||
|
||||
/// 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<TreeNode> {
|
||||
let Some(s) = self.series.first() else { return vec![] };
|
||||
let n = self.categories.len().max(s.values.len());
|
||||
let levels: Vec<&Vec<String>> = 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<String> = vec![String::new(); levels.len()];
|
||||
let paths: Vec<Vec<String>> = (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<TreeNode> {
|
||||
let mut out: Vec<TreeNode> = 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<_>>(), 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<_>>(), vec![6, 1]);
|
||||
c.bin_size = None;
|
||||
let auto = c.histogram_bins();
|
||||
assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::<usize>() == 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::<Vec<_>>(), ["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::<Vec<_>>(), ["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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<f64>),
|
||||
/// `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<TrendFit> {
|
||||
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<f64> = 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<f64> = 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<f64>, Vec<f64>) = 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<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = 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<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), *y)).unzip();
|
||||
let (b, c) = linreg(&ln_x, &ys, intercept)?;
|
||||
let yhat: Vec<f64> = 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<f64>, Vec<f64>) = 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<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = 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::<f64>() / 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<f64> {
|
||||
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<f64>, backward: Option<f64>) -> (f64, f64) {
|
||||
let pad = |v: Option<f64>| 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<String> {
|
||||
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<f64> {
|
||||
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<f64>, Vec<f64>) {
|
||||
pairs.iter().map(|(x, y)| (*x, *y)).unzip()
|
||||
}
|
||||
|
||||
/// `y = b + m x`. `intercept` fixes `b`.
|
||||
fn linreg(xs: &[f64], ys: &[f64], intercept: Option<f64>) -> 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<f64>) -> Option<Vec<f64>> {
|
||||
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::<f64>() / n as f64;
|
||||
let us: Vec<f64> = 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<f64> {
|
||||
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<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
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::<f64>() / 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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ fn simple_line(sp: Option<&El>) -> Option<deckcraft_model::Line> {
|
||||
Some(l)
|
||||
}
|
||||
|
||||
fn rich_text(t: &El) -> Option<String> {
|
||||
pub(crate) fn rich_text(t: &El) -> Option<String> {
|
||||
let tx = t.child("tx")?;
|
||||
if let Some(r) = tx.child("rich") {
|
||||
let s = r.children_named("p").map(|p| p.text()).collect::<Vec<_>>().join("\n");
|
||||
@@ -188,18 +188,28 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
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::<f64>().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::<f64>().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::<f64>().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<Chart> {
|
||||
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<Trendline> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The colour of the first `a:defRPr` or `a:rPr` with a solid fill under `e`.
|
||||
fn text_color(e: Option<&El>) -> Option<deckcraft_model::ColorRef> {
|
||||
fn rec(e: &El, depth: usize) -> Option<deckcraft_model::ColorRef> {
|
||||
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<f64> {
|
||||
fn rec(e: &El, depth: usize) -> Option<f64> {
|
||||
@@ -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#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:lineChart><c:axId val="1"/><c:axId val="2"/></c:lineChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/><c:scaling><c:logBase val="10"/><c:orientation val="minMax"/></c:scaling></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
assert_eq!(read_chart_xml(xml).unwrap().y_log_base, Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legend_position_and_overlay_are_read() {
|
||||
let legend = r#"<c:legend><c:legendPos val="r"/><c:overlay val="1"/></c:legend>"#;
|
||||
@@ -472,6 +524,23 @@ mod tests {
|
||||
assert_eq!(fmt(r#"<c:numFmt formatCode="0.0" sourceLinked="0"/>"#).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#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:val><c:numRef><c:numCache><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/><c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr sz="1000"><a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill></a:defRPr></a:pPr></a:p></c:txPr></c:catAx><c:valAx><c:axId val="2"/></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
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("<c:catAx><c:axId val=\"1\"/>", "<c:catAx><c:axId val=\"1\"/><c:scaling><c:orientation val=\"maxMin\"/></c:scaling>")
|
||||
.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
|
||||
|
||||
@@ -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<String>,
|
||||
/// Outer category levels, innermost first.
|
||||
levels: Vec<Vec<String>>,
|
||||
numbers: HashMap<String, (Vec<Option<f64>>, Option<String>)>,
|
||||
}
|
||||
|
||||
/// A level's points, by `idx`.
|
||||
fn points(lvl: &El) -> Vec<String> {
|
||||
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::<f64>().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<ChartType> {
|
||||
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<Chart> {
|
||||
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<Chart> {
|
||||
if !root.is("chartSpace") {
|
||||
return None;
|
||||
}
|
||||
let datas: HashMap<String, Data> =
|
||||
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::<f64>().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)
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
kept_chartex(c).or_else(|| c.kind.is_chartex().then(|| build_chartex(c)))
|
||||
}
|
||||
|
||||
fn kept_chartex(c: &Chart) -> Option<Vec<u8>> {
|
||||
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<u8> {
|
||||
// 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<u8> {
|
||||
| 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<u8> {
|
||||
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<String> {
|
||||
let doc = crate::xml::parse(raw.as_bytes()).ok()?;
|
||||
let mut root = doc.root;
|
||||
crate::read::resolve_mc(&mut root, 0);
|
||||
let region = root.child("chart")?.child("plotArea")?.child("plotAreaRegion")?;
|
||||
let s = region.children_named("series").find(|s| s.attr("layoutId") == Some("regionMap"))?;
|
||||
let lp = s.child("layoutPr")?;
|
||||
// Written under our `cx` prefix; a part that bound chartex to another prefix is not copied.
|
||||
lp.name.starts_with("cx:").then(|| lp.to_xml())
|
||||
}
|
||||
|
||||
/// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data`
|
||||
/// per series (categories and values), then the plot area with each series' layout, the axes
|
||||
/// the layout uses, the title and the legend.
|
||||
pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
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<String> = s.values.iter().map(|v| v.map(fmt_num).unwrap_or_default()).collect();
|
||||
cx_level(&mut w, &vals, Some(s.number_format.as_deref().unwrap_or("General")));
|
||||
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#"<cx:chartSpace xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex"><cx:chartData><cx:data id="0"><cx:strDim type="cat"><cx:f>Sheet1!$A$2:$A$4</cx:f><cx:lvl ptCount="3"><cx:pt idx="0">Minsk</cx:pt><cx:pt idx="1">Brest</cx:pt><cx:pt idx="2">Gomel</cx:pt></cx:lvl></cx:strDim><cx:numDim type="colorVal"><cx:f>Sheet1!$B$2:$B$4</cx:f><cx:lvl ptCount="3" formatCode="General"><cx:pt idx="0">12</cx:pt><cx:pt idx="1">5</cx:pt><cx:pt idx="2">8</cx:pt></cx:lvl></cx:numDim></cx:data></cx:chartData><cx:chart><cx:title pos="t" align="ctr" overlay="0"/><cx:plotArea><cx:plotAreaRegion><cx:series layoutId="regionMap" uniqueId="{00000000-0000-4000-8000-000000000001}"><cx:tx><cx:txData><cx:f>Sheet1!$B$1</cx:f><cx:v>Sales</cx:v></cx:txData></cx:tx><cx:dataId val="0"/><cx:layoutPr><cx:geography cultureLanguage="en-US" cultureRegion="BY" attribution="Powered by Bing"><cx:geoCache provider="{E9337A44-BEBE-4D9F-B70C-5C5E7DAFC167}"><cx:binary>ZGVja2NyYWZ0LXRlc3Q=</cx:binary></cx:geoCache></cx:geography></cx:layoutPr></cx:series></cx:plotAreaRegion></cx:plotArea><cx:legend pos="r" align="min" overlay="0"/></cx:chart></cx:chartSpace>"#;
|
||||
|
||||
#[test]
|
||||
fn region_maps_keep_their_geography_through_save_and_edit() {
|
||||
let c = crate::read::read_chartex_xml(MAP.as_bytes()).expect("a region map is read");
|
||||
assert_eq!(c.kind, ChartType::RegionMap);
|
||||
assert_eq!(c.categories, ["Minsk", "Brest", "Gomel"]);
|
||||
assert_eq!(c.series.first().map(|s| s.values.clone()), Some(vec![Some(12.0), Some(5.0), Some(8.0)]));
|
||||
// Unchanged: written back as read.
|
||||
let kept = String::from_utf8(chartex_xml(&c).unwrap_or_default()).unwrap_or_default();
|
||||
assert!(kept.contains("ZGVja2NyYWZ0LXRlc3Q=") && kept.contains("cultureRegion=\"BY\""), "{kept}");
|
||||
// Edited: rebuilt from the model, with the geography carried over.
|
||||
let mut e = c.clone();
|
||||
if let Some(s) = e.series.first_mut() {
|
||||
s.values[1] = Some(20.0);
|
||||
}
|
||||
let built = String::from_utf8(chartex_xml(&e).unwrap_or_default()).unwrap_or_default();
|
||||
assert!(built.contains("<cx:geoCache") && built.contains("ZGVja2NyYWZ0LXRlc3Q=") && built.contains("colorVal"), "{built}");
|
||||
assert!(!built.contains("<cx:axis"), "{built}");
|
||||
let back = crate::read::read_chartex_xml(built.as_bytes()).expect("the rebuilt part reads");
|
||||
assert_eq!(back.kind, ChartType::RegionMap);
|
||||
assert_eq!(back.series.first().and_then(|s| s.values.get(1).copied().flatten()), Some(20.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
|
||||
#[test]
|
||||
fn reversed_category_axis_round_trips() {
|
||||
let mut c = Chart::sample(ChartType::Bar);
|
||||
c.cat_reversed = true;
|
||||
let xml = super::chart_xml(&c);
|
||||
assert!(String::from_utf8_lossy(&xml).contains(r#"<c:orientation val="maxMin"/>"#));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SlideId, String>,
|
||||
pub table_styles: Vec<String>,
|
||||
placeholder: Option<String>,
|
||||
@@ -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<String> {
|
||||
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<Vec<u8>, PptxError> {
|
||||
n_chart: 0,
|
||||
n_diagram: 0,
|
||||
n_tags: 0,
|
||||
n_chartex: 0,
|
||||
slide_parts: HashMap::new(),
|
||||
table_styles: vec![],
|
||||
placeholder: None,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<cx:chartSpace xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex"><cx:chartData><cx:externalData r:id="rId1" cx:autoUpdate="0"/><cx:data id="0"><cx:strDim type="cat"><cx:f>Sheet1!$A$2:$A$5</cx:f><cx:lvl ptCount="4"><cx:pt idx="0">Start</cx:pt><cx:pt idx="1">Sales</cx:pt><cx:pt idx="2">Costs</cx:pt><cx:pt idx="3">End</cx:pt></cx:lvl></cx:strDim><cx:numDim type="val"><cx:f>Sheet1!$B$2:$B$5</cx:f><cx:lvl ptCount="4" formatCode="General"><cx:pt idx="0">100</cx:pt><cx:pt idx="1">40</cx:pt><cx:pt idx="2">-25</cx:pt><cx:pt idx="3">115</cx:pt></cx:lvl></cx:numDim></cx:data></cx:chartData><cx:chart><cx:title pos="t" align="ctr" overlay="0"><cx:tx><cx:txData><cx:v>Cash flow</cx:v></cx:txData></cx:tx></cx:title><cx:plotArea><cx:plotAreaRegion><cx:series layoutId="waterfall" uniqueId="{1}"><cx:tx><cx:txData><cx:f>Sheet1!$B$1</cx:f><cx:v>Amount</cx:v></cx:txData></cx:tx><cx:dataLabels pos="outEnd"><cx:visibility seriesName="0" categoryName="0" value="1"/></cx:dataLabels><cx:dataId val="0"/><cx:layoutPr><cx:subtotals><cx:idx val="0"/><cx:idx val="3"/></cx:subtotals></cx:layoutPr></cx:series></cx:plotAreaRegion><cx:axis id="0"><cx:catScaling gapWidth="0.5"/><cx:tickLabels/></cx:axis><cx:axis id="1"><cx:valScaling/><cx:majorGridlines/><cx:tickLabels/></cx:axis></cx:plotArea><cx:legend pos="b" align="ctr" overlay="0"/></cx:chart></cx:chartSpace>"#;
|
||||
|
||||
/// A waterfall frame as PowerPoint writes it: the chart in a `cx1` choice, a text box fallback.
|
||||
const WATERFALL_FRAME: &str = r#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" Requires="cx1"><p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="90" name="Waterfall 1"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="914400" y="914400"/><a:ext cx="6096000" cy="4064000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/drawing/2014/chartex"><cx:chart xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" r:id="rIdCx"/></a:graphicData></a:graphic></p:graphicFrame></mc:Choice><mc:Fallback><p:sp><p:nvSpPr><p:cNvPr id="90" name="Waterfall 1"/><p:cNvSpPr><a:spLocks noTextEdit="1"/></p:cNvSpPr><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="6096000" cy="4064000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:p><a:r><a:t>This chart isn't available in your version of PowerPoint.</a:t></a:r></a:p></p:txBody></p:sp></mc:Fallback></mc:AlternateContent>"#;
|
||||
|
||||
/// 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#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/></Relationships>"#;
|
||||
let pptx = repack(
|
||||
&base,
|
||||
|name, x| {
|
||||
if name == slide {
|
||||
x.replacen("</p:spTree>", &format!("{WATERFALL_FRAME}</p:spTree>"), 1)
|
||||
} else if name == slide_rels {
|
||||
x.replace("</Relationships>", r#"<Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="../charts/chartEx1.xml"/></Relationships>"#)
|
||||
} else if name == "[Content_Types].xml" {
|
||||
x.replace(
|
||||
"</Types>",
|
||||
r#"<Override PartName="/ppt/charts/chartEx1.xml" ContentType="application/vnd.ms-office.chartex+xml"/></Types>"#,
|
||||
)
|
||||
} 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<Chart> {
|
||||
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<Chart> {
|
||||
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<Chart> {
|
||||
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<Chart> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+526
-34
@@ -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<f64> = 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<f64> = 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<f64> = 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>) -> 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<Point> {
|
||||
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<Point> = 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<f64> = 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<f64>)> =
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), s.values.get(i).copied().flatten())).filter(|(n, _)| !n.is_empty()).collect();
|
||||
let note_h = base * 1.4;
|
||||
let scale_h = base * 2.6;
|
||||
let grid = Rect::new(area.x0, area.y0, area.x1, (area.y1 - note_h - scale_h).max(area.y0));
|
||||
let hi = series_color(rctx, c, 0);
|
||||
let lo = mix(hi, Rgba::WHITE, 0.85);
|
||||
let (min, max) = regions.iter().filter_map(|(_, v)| *v).fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(v), b.max(v)));
|
||||
let span = if max > min { max - min } else { 1.0 };
|
||||
let n = regions.len();
|
||||
if n > 0 && grid.area() > 0.0 {
|
||||
// Columns so that tiles come out about twice as wide as tall.
|
||||
let aspect = grid.width() / grid.height().max(1.0);
|
||||
let cols = ((n as f64 * aspect / 2.0).sqrt().ceil() as usize).clamp(1, n);
|
||||
let rows = n.div_ceil(cols).max(1);
|
||||
let (tw, th) = (grid.width() / cols as f64, grid.height() / rows as f64);
|
||||
for (i, (name, v)) in regions.iter().enumerate() {
|
||||
let (col, row) = (i % cols, i / cols);
|
||||
let t = Rect::new(grid.x0 + tw * col as f64, grid.y0 + th * row as f64, grid.x0 + tw * (col + 1) as f64, grid.y0 + th * (row + 1) as f64)
|
||||
.inset(-1.5);
|
||||
let fill = v.map_or(Rgba::rgb(0xD9, 0xD9, 0xD9), |v| mix(lo, hi, (v - min) / span));
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
ctx.fill_rect(&t);
|
||||
let dark = v.is_some_and(|v| (v - min) / span > 0.55);
|
||||
let mut text = name.clone();
|
||||
if c.data_labels
|
||||
&& let Some(v) = v
|
||||
{
|
||||
text = format!(
|
||||
"{text}
|
||||
{}",
|
||||
c.data_label(s, *v)
|
||||
);
|
||||
}
|
||||
let inner = t.inset(-base * 0.25);
|
||||
if inner.width() > base && inner.height() > base * 0.8 {
|
||||
label(ctx, rctx, m, &text, inner, base * 0.9, Align::Center, Anchor::Middle, if dark { Rgba::WHITE } else { text_col }, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The colour scale: a gradient bar from the lowest to the highest value.
|
||||
if min <= max {
|
||||
let bar_w = (area.width() * 0.5).min(base * 24.0);
|
||||
let y = grid.y1 + base * 0.6;
|
||||
let bar = Rect::new(area.center().x - bar_w / 2.0, y, area.center().x + bar_w / 2.0, y + base * 0.8);
|
||||
let steps = 48;
|
||||
for k in 0..steps {
|
||||
let x0 = bar.x0 + bar.width() * k as f64 / steps as f64;
|
||||
let x1 = bar.x0 + bar.width() * (k + 1) as f64 / steps as f64 + 0.5;
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(mix(lo, hi, k as f64 / (steps - 1) as f64), 1.0));
|
||||
ctx.fill_rect(&Rect::new(x0, bar.y0, x1.min(bar.x1), bar.y1));
|
||||
}
|
||||
let ly = Rect::new(bar.x0 - base * 8.0, bar.y0 - base * 0.2, bar.x0 - base * 0.4, bar.y1 + base * 0.2);
|
||||
label(ctx, rctx, m, &c.data_label(s, min), ly, base, Align::Right, Anchor::Middle, text_col, false);
|
||||
let ry = Rect::new(bar.x1 + base * 0.4, bar.y0 - base * 0.2, bar.x1 + base * 8.0, bar.y1 + base * 0.2);
|
||||
label(ctx, rctx, m, &c.data_label(s, max), ry, base, Align::Left, Anchor::Middle, text_col, false);
|
||||
}
|
||||
let note = Rect::new(area.x0, area.y1 - note_h, area.x1, area.y1);
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
"Map preview unavailable: regions shown as tiles",
|
||||
note,
|
||||
base * 0.85,
|
||||
Align::Center,
|
||||
Anchor::Middle,
|
||||
mix(text_col, Rgba::WHITE, 0.4),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Squarified treemap (Bruls, Huizing, van Wijk): rows of tiles laid along the short side,
|
||||
/// each row kept while it improves the worst aspect ratio.
|
||||
pub fn squarify(vals: &[f64], r: Rect) -> Vec<Rect> {
|
||||
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<usize> = (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<f64> = 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<f64> = 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<Vec<Option<BoxStats>>>,
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Point> = (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<f64> = 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);
|
||||
}
|
||||
}
|
||||
+403
-1
@@ -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<String>, Option<String>)| {
|
||||
@@ -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<u32> = (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<usize>| {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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<Option<deckcraft_model::chart::ChartType>> {
|
||||
use deckcraft_model::chart::ChartType;
|
||||
let mut v: Vec<Option<ChartType>> = 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::*;
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+19
-6
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user