diff --git a/Cargo.lock b/Cargo.lock index 9ab6b0dd0..c66f79209 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -985,6 +985,7 @@ dependencies = [ "craft-xml", "kurbo 0.13.1", "serde", + "serde_json", ] [[package]] diff --git a/crates/drawingml/Cargo.toml b/crates/drawingml/Cargo.toml index a643688ae..6873a91e1 100644 --- a/crates/drawingml/Cargo.toml +++ b/crates/drawingml/Cargo.toml @@ -14,3 +14,6 @@ workspace = true craft-xml = { workspace = true } kurbo = { workspace = true } serde = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/drawingml/README.md b/crates/drawingml/README.md index 3f7f1375d..a58b039c2 100644 --- a/crates/drawingml/README.md +++ b/crates/drawingml/README.md @@ -16,7 +16,20 @@ files store EMU (12 700 per point). | `custgeom` | `read_cust_geom` / `write_cust_geom` between `a:custGeom` and `CustomPath` (an `M L C Q Z` path string per sub-path), and `Guides`, the shape guide formula evaluator | `a:custGeom`, `a:gd` | | `warp` | `warp(name, adjusts, rect)`: the bands and guide curves of a preset text warp, and the mapping of laid-out lines onto them | `a:prstTxWarp` | -Fills, lines, effects, text bodies, tables, themes and charts are not here yet. +## The model + +These modules hold types only; their readers and writers are not here yet. + +| Module | Types | Markup they stand for | +|---|---|---| +| `style` | `ColorRef` (a base colour plus transforms, resolved against a `ColorScheme`), `Fill`, `Gradient`, `PictureFill`, `Line`, `Effects`, `ShapeStyle` | `a:solidFill`, `a:gradFill`, `a:blipFill`, `a:pattFill`, `a:ln`, `a:effectLst`, `p:style` | +| `text` | `TextBody`, `Paragraph`, `ParaProps`, `Run`, `RunProps`, `ListStyle`, hyperlinks and actions | `a:txBody`, `a:bodyPr`, `a:lstStyle`, `a:p`, `a:r` | +| `table` | `Table`, `Row`, `Cell`, `TableStyleDef` and the built-in table styles | `a:tbl`, `a:tblStyleLst` | +| `chart` | `Chart`, `Series`, axes, data labels, trendlines and error bars, with the values they compute | `c:chartSpace`, `cx:chartSpace` | +| `numfmt` | `format(code, value, lang)`: number format codes as charts and cells use them | `c:numFmt` | +| `diagram` | `Diagram`, `DiagramNode` | `dgm:dataModel` | + +A picture or sound is a `MediaId`; whoever owns the package keeps the bytes. Themes are not here yet. ## Preset shapes and text warps diff --git a/crates/drawingml/src/chart.rs b/crates/drawingml/src/chart.rs new file mode 100644 index 000000000..c93d12f25 --- /dev/null +++ b/crates/drawingml/src/chart.rs @@ -0,0 +1,1678 @@ +//! Charts: an embedded data grid plus type and element options. + +use serde::{Deserialize, Serialize}; + +use crate::style::{ColorRef, Fill, Line}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChartType { + #[default] + Column, + StackedColumn, + PercentColumn, + Bar, + StackedBar, + PercentBar, + Line, + LineMarkers, + StackedLine, + Area, + StackedArea, + Pie, + Doughnut, + Scatter, + Bubble, + Radar, + FilledRadar, + Stock, + Surface, + Histogram, + Pareto, + BoxWhisker, + Waterfall, + 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, + ChartType::Line, + ChartType::Area, + ChartType::Pie, + ChartType::Doughnut, + ChartType::Scatter, + ChartType::Radar, + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Treemap, + ]; + pub fn label(self) -> &'static str { + match self { + ChartType::Column => "Clustered Column", + ChartType::StackedColumn => "Stacked Column", + ChartType::PercentColumn => "100% Stacked Column", + ChartType::Bar => "Clustered Bar", + ChartType::StackedBar => "Stacked Bar", + ChartType::PercentBar => "100% Stacked Bar", + ChartType::Line => "Line", + ChartType::LineMarkers => "Line with Markers", + ChartType::StackedLine => "Stacked Line", + ChartType::Area => "Area", + ChartType::StackedArea => "Stacked Area", + ChartType::Pie => "Pie", + ChartType::Doughnut => "Doughnut", + ChartType::Scatter => "Scatter", + ChartType::Bubble => "Bubble", + ChartType::Radar => "Radar", + ChartType::FilledRadar => "Filled Radar", + ChartType::Stock => "Stock", + ChartType::Surface => "Surface", + ChartType::Histogram => "Histogram", + ChartType::Pareto => "Pareto", + ChartType::BoxWhisker => "Box and Whisker", + ChartType::Waterfall => "Waterfall", + ChartType::Funnel => "Funnel", + ChartType::Treemap => "Treemap", + ChartType::Sunburst => "Sunburst", + ChartType::RegionMap => "Map", + ChartType::Combo => "Combo", + } + } + pub fn is_bar_like(self) -> bool { + matches!( + self, + ChartType::Column + | ChartType::StackedColumn + | ChartType::PercentColumn + | ChartType::Bar + | ChartType::StackedBar + | ChartType::PercentBar + | ChartType::Histogram + | ChartType::Pareto + | ChartType::Waterfall + ) + } + pub fn horizontal(self) -> bool { + matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel) + } + pub fn stacked(self) -> bool { + matches!( + self, + ChartType::StackedColumn + | ChartType::StackedBar + | ChartType::StackedLine + | ChartType::StackedArea + | ChartType::PercentColumn + | ChartType::PercentBar + ) + } + pub fn percent(self) -> bool { + matches!(self, ChartType::PercentColumn | ChartType::PercentBar) + } +} + +/// `c:trendlineType`: the six trendlines DrawingML charts store. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TrendlineType { + Exp, + #[default] + Linear, + Log, + MovingAvg, + Poly, + Power, +} + +impl TrendlineType { + pub fn xml(self) -> &'static str { + match self { + TrendlineType::Exp => "exp", + TrendlineType::Linear => "linear", + TrendlineType::Log => "log", + TrendlineType::MovingAvg => "movingAvg", + TrendlineType::Poly => "poly", + TrendlineType::Power => "power", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "exp" => TrendlineType::Exp, + "linear" => TrendlineType::Linear, + "log" => TrendlineType::Log, + "movingAvg" => TrendlineType::MovingAvg, + "poly" => TrendlineType::Poly, + "power" => TrendlineType::Power, + _ => return None, + }) + } +} + +/// One `c:trendline` on a series. Polynomials use `order` (2–6); moving averages use `period`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Trendline { + pub kind: TrendlineType, + /// `c:name`, when the file sets one. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Polynomial order (`c:order`). + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// Moving-average period (`c:period`). + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + /// Forecast forward and backward, in category units (`c:forward`, `c:backward`). + #[serde(skip_serializing_if = "Option::is_none")] + pub forward: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub backward: Option, + /// Cross the value axis at this value (`c:intercept`). + #[serde(skip_serializing_if = "Option::is_none")] + pub intercept: Option, + /// Show the equation (`c:dispEq`). + pub disp_eq: bool, + /// Show R² (`c:dispRSqr`). + pub disp_r_sqr: bool, +} + +impl Default for Trendline { + fn default() -> Self { + Trendline { + kind: TrendlineType::Linear, + name: None, + order: None, + period: None, + forward: None, + backward: None, + intercept: None, + disp_eq: false, + disp_r_sqr: false, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Series { + pub name: String, + pub values: Vec>, + /// X values for scatter/bubble. + pub x_values: Vec>, + pub sizes: Vec>, + pub fill: Option, + pub line: Option, + /// Per-point fill overrides (pie slices). + pub point_fills: Vec<(usize, Fill)>, + pub data_labels: Option, + pub smooth: bool, + pub marker: Option, + /// Combo charts: this series' own type. + pub kind: Option, + pub secondary_axis: bool, + /// The number format of the series' data labels (Excel code): the labels' own, else the + /// source data's. + #[serde(skip_serializing_if = "Option::is_none")] + pub number_format: Option, + /// Trendlines (`c:trendline`), in file order. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub trendlines: Vec, + /// `c:errBars` on the values (y bars; a bar chart's run along its value axis). + #[serde(skip_serializing_if = "Option::is_none")] + pub error_bars: Option, +} + +/// Error bars (`c:errBars`): how far each point's bar reaches below and above it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ErrorBars { + /// `both`, `plus` or `minus`. + pub bar_type: String, + /// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`. + pub val_type: String, + pub value: f64, + /// `cust`: the per-point plus and minus amounts. + pub plus: Vec>, + pub minus: Vec>, + pub end_cap: bool, +} + +impl Default for ErrorBars { + fn default() -> Self { + ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true } + } +} + +/// Count, mean and sample standard deviation of a series' finite values (for error bars). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SeriesStats { + pub n: f64, + pub mean: f64, + pub sd: f64, +} + +impl SeriesStats { + pub fn of(values: &[Option]) -> Self { + let vals = || values.iter().flatten().copied().filter(|v| v.is_finite()); + let n = vals().count() as f64; + let mean = vals().sum::() / n.max(1.0); + let sd = if n > 1.0 { (vals().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0)).sqrt() } else { 0.0 }; + SeriesStats { n, mean, sd } + } +} + +impl ErrorBars { + /// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units. + /// Standard deviation bars stand around the series' mean, as Excel draws them; the others + /// around the point. `None` for an amount that isn't a finite number. + pub fn range(&self, values: &[Option], i: usize, v: f64) -> Option<(f64, f64)> { + self.range_in(&SeriesStats::of(values), i, v) + } + + /// [`ErrorBars::range`] with the series' statistics worked out once ([`SeriesStats::of`]), so + /// drawing every point of a long series stays linear. + pub fn range_in(&self, stats: &SeriesStats, i: usize, v: f64) -> Option<(f64, f64)> { + let SeriesStats { n, mean, sd } = *stats; + let (centre, minus, plus) = match self.val_type.as_str() { + "percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()), + "stdDev" => (mean, sd * self.value, sd * self.value), + "stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()), + "cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)), + _ => (v, self.value, self.value), + }; + let (lo, hi) = match self.bar_type.as_str() { + "plus" => (centre, centre + plus), + "minus" => (centre - minus, centre), + _ => (centre - minus, centre + plus), + }; + (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Chart { + pub kind: ChartType, + pub title: Option, + pub categories: Vec, + pub series: Vec, + pub legend: Option, + /// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it. + pub legend_overlay: bool, + /// Text size in points of the axis labels and legend (from their `c:txPr`, else the chart's); + /// `None`: the file gives none. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_size: Option, + /// Title text size in points (its rich text or `c:txPr`). + #[serde(skip_serializing_if = "Option::is_none")] + pub title_size: Option, + pub data_labels: bool, + pub gridlines: bool, + /// `c:minorGridlines` on the value axis: five to a major unit, between the major ones. + pub minor_gridlines: bool, + pub axis_titles: (Option, Option), + /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). + pub style: u32, + pub palette: String, + pub plot_fill: Option, + pub chart_fill: Option, + pub text_color: Option, + pub hole_size: f64, + pub gap_width: f64, + pub overlap: f64, + pub vary_colors: bool, + pub y_min: Option, + pub y_max: Option, + /// The value axis' major unit, when the chart fixes it. + pub y_major: Option, + /// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000). + pub y_log_base: Option, + /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). + pub y_format: Option, + /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. + pub y_hidden: bool, + /// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a + /// horizontal bar chart top to bottom (the default puts the first category at the bottom). + pub cat_reversed: bool, + /// A combo chart's secondary value axis: bounds, major unit and number format. + pub y2_min: Option, + pub y2_max: Option, + pub y2_major: Option, + pub y2_format: Option, + pub y2_hidden: bool, + /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. + pub lang: Option, + /// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart + /// the running total, instead of floating. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub subtotals: Vec, + /// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents, + /// then theirs…), each with one label per category. A blank label continues the one above it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub category_levels: Vec>, + /// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub inclusive_quartiles: bool, + /// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between + /// the first and last series, hollow when the price rose. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub up_down_bars: bool, + /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are + /// automatic (Scott's rule, as Excel does). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_count: Option, + /// A histogram's bin width (`cx:binSize`). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_size: Option, + /// Original chart XML part kept for round-trip of unmodelled options. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + /// The chart was too large to model: `raw` and `kept` are written back unchanged on save. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub verbatim: bool, + /// Package parts the original chart still points at (embedded workbook, styles, drawings). + /// The PPTX writer clears this before comparing modelled state. Native saves keep it too. + #[serde(skip_serializing_if = "ChartKept::is_empty")] + pub kept: ChartKept, +} + +/// One relationship of a chart part, with the target string as it was written. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartRel { + pub id: String, + pub rel_type: String, + pub target: String, + /// Absolute name of the part an internal relationship reaches (matches a `ChartPart::name` + /// up to case); empty for external ones. The writer renames kept parts and uses this to + /// point the relationship at the new name. + pub part: String, + pub external: bool, +} + +/// A part reached from a chart (the workbook, a drawing, an image it uses). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartPart { + /// Absolute part name (`ppt/embeddings/Workbook.xlsx`). + pub name: String, + pub content_type: String, + /// Runtime bytes; older native files stored these inline in the manifest. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bytes: Vec, + /// Native ZIP member while encoding/decoding. The native reader consumes this reference. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_entry: Option, +} + +/// Sidecar for an unchanged chart: relationships and the parts they reach. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ChartKept { + pub rels: Vec, + pub parts: Vec, +} + +impl ChartKept { + fn is_empty(&self) -> bool { + self.rels.is_empty() && self.parts.is_empty() + } +} + +impl Default for Chart { + fn default() -> Self { + Chart { + kind: ChartType::Column, + title: Some("Chart Title".into()), + categories: vec![], + series: vec![], + legend: Some("b".into()), + legend_overlay: false, + text_size: None, + title_size: None, + data_labels: false, + gridlines: true, + minor_gridlines: false, + axis_titles: (None, None), + style: 2, + palette: "colorful1".into(), + plot_fill: None, + chart_fill: None, + text_color: None, + hole_size: 0.5, + gap_width: 2.19, + overlap: -0.27, + vary_colors: false, + 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, + verbatim: false, + kept: ChartKept::default(), + } + } +} + +impl Chart { + fn sample_treemap_sunburst(kind: ChartType) -> Self { + let mut c = Chart { + kind, + categories: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(ToString::to_string).collect(), + category_levels: vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(ToString::to_string).collect()], + vary_colors: true, + ..Default::default() + }; + 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 + } + + fn sample_observation_chart(kind: ChartType) -> Self { + 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 }; + let categories = if kind == ChartType::BoxWhisker { + (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() + } else { + vec![] + }; + let mut c = + Chart { kind, categories, gridlines: true, legend: (kind == ChartType::BoxWhisker).then(|| "b".to_string()), ..Default::default() }; + 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 + } + + fn sample_stock() -> Self { + let mut c = Chart { + kind: ChartType::Stock, + categories: ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(ToString::to_string).collect(), + ..Default::default() + }; + 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() }); + } + c + } + + /// The sample data a new chart starts with (our own numbers). + pub fn sample(kind: ChartType) -> Self { + if matches!(kind, ChartType::Treemap | ChartType::Sunburst) { + return Self::sample_treemap_sunburst(kind); + } + if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + return Self::sample_observation_chart(kind); + } + if kind == ChartType::Stock { + return Self::sample_stock(); + } + 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(ToString::to_string).collect(), ..Default::default() }; + 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 { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: data.iter().map(|row| row.get(s).copied()).collect(), + x_values: if kind == ChartType::Scatter { (1..=4).map(|x| Some(x as f64)).collect() } else { vec![] }, + ..Default::default() + }); + } + 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 + /// chart's. + pub fn series_kind(&self, s: &Series) -> ChartType { + match (self.kind, s.kind) { + (ChartType::Combo, Some(k)) if k != ChartType::Combo => k, + (ChartType::Combo, _) => ChartType::Column, + (k, _) => k, + } + } + + /// Whether a series is plotted against the secondary value axis (combo charts only). + pub fn on_secondary(&self, s: &Series) -> bool { + self.kind == ChartType::Combo && s.secondary_axis + } + + /// The value range of one value axis: the primary axis (`y_min`/`y_max` apply), or a combo + /// chart's secondary axis. A combo chart stacks only its stacked series. `None` when no series + /// uses that axis. + pub fn axis_range(&self, secondary: bool) -> Option<(f64, f64)> { + if self.kind != ChartType::Combo { + return (!secondary).then(|| self.value_range()); + } + let (lo, hi) = self.plotted_extent(secondary)?; + let (lo, hi) = (lo.min(0.0), hi.max(0.0)); + Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) }) + } + + /// The smallest and largest value plotted against one value axis: plain values as they are, + /// 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)); + } + let mut ext = (f64::INFINITY, f64::NEG_INFINITY); + let has_stacked_or_percent = ser.iter().any(|s| { + let k = self.series_kind(s); + k.stacked() || k.percent() + }); + let percent = if has_stacked_or_percent { + self.fold_stacked(&ser, &mut ext) + } else { + for s in &ser { + for v in s.values.iter().flatten().copied().filter(|v| v.is_finite()) { + widen(&mut ext, v); + } + } + false + }; + if !percent { + self.fold_trendlines(&ser, &mut ext); + } + if percent { + ext.0 = ext.0.min(0.0); + ext.1 = ext.1.max(1.0); + } + let (lo, hi) = ext; + Some(if lo <= hi { (lo, hi) } else { (0.0, 0.0) }) + } + + /// Widens `ext` by the points of the stacked and 100 % series in `ser`; returns whether a + /// 100 % series is among them. + fn fold_stacked(&self, ser: &[&Series], ext: &mut (f64, f64)) -> bool { + let mut percent = false; + let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0); + let budget = (ser.len() * n).min(10_000_000); + let n_clamped = if ser.is_empty() { 0 } else { n.min(budget / ser.len()) }; + for i in 0..n_clamped { + let (mut p, mut m, mut stacked) = (0.0, 0.0, false); + for s in ser { + let k = self.series_kind(s); + let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + if k.percent() { + percent = true; + } else if k.stacked() { + stacked = true; + if v >= 0.0 { p += v } else { m += v } + } else { + widen(ext, v); + } + } + if stacked { + ext.1 = ext.1.max(p); + ext.0 = ext.0.min(m); + } + } + percent + } + + /// Widens `ext` by the trendlines of the series in `ser` that are not stacked. + fn fold_trendlines(&self, ser: &[&Series], ext: &mut (f64, f64)) { + 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))) { + widen(ext, y); + } + } + } + + /// 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. + pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> { + let (lo, hi) = self.plotted_extent(secondary)?; + let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) }; + let full_percent = !secondary && self.kind.percent(); + 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 }; + // A 100 % stacked chart's value axis counts in percent unless the file says otherwise. + let code = match code.as_deref() { + None | Some("General") if !secondary && self.kind.percent() => "0%", + c => c.unwrap_or("General"), + }; + crate::numfmt::format(code, v, self.lang.as_deref()) + } + + /// A data label of series `s`: its number format, else its axis', in the chart's language. + pub fn data_label(&self, s: &Series, v: f64) -> String { + let axis = if self.on_secondary(s) { &self.y2_format } else { &self.y_format }; + let code = s.number_format.as_ref().or(axis.as_ref()); + crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref()) + } + + /// Does series `s` show its data labels? The series' own `c:dLbls/c:showVal` wins — an + /// explicit `false` hides them even when the chart-wide setting is on (#868). + pub fn series_data_labels(&self, s: &Series) -> bool { + s.data_labels.unwrap_or(self.data_labels) + } + + fn waterfall_value_range(&self) -> (f64, f64) { + let mut lo: f64 = 0.0; + let mut hi: f64 = 0.0; + for (a, b) in self.waterfall_bars() { + lo = lo.min(a.min(b)); + hi = hi.max(a.max(b)); + } + (lo, hi) + } + + fn stacked_value_range(&self, n: usize) -> (f64, f64) { + // Sums per category, from the values the series hold (an absent one adds nothing). + let (mut pos, mut neg) = (vec![0.0f64; n], vec![0.0f64; n]); + for s in &self.series { + for (i, v) in s.values.iter().enumerate().take(n) { + let v = v.unwrap_or(0.0); + if v >= 0.0 { + pos[i] += v; + } else { + neg[i] += v; + } + } + } + let hi = pos.iter().fold(0.0f64, |a, &p| a.max(p)); + let lo = neg.iter().fold(0.0f64, |a, &m| a.min(m)); + (lo, hi) + } + + /// Value range across series (stacked sums for stacked types). + pub fn value_range(&self) -> (f64, f64) { + if self.kind == ChartType::Waterfall { + return self.waterfall_value_range(); + } + let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); + let (mut lo, mut hi) = if self.kind.stacked() { + self.stacked_value_range(n) + } else { + let mut l: f64 = 0.0; + let mut h: f64 = 0.0; + for s in &self.series { + for v in s.values.iter().flatten() { + if v.is_finite() { + h = h.max(*v); + l = l.min(*v); + } + } + } + (l, h) + }; + 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)) + } +} + +/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major` +/// where the chart fixes them; otherwise Office's automatic limits, rounded out to the smallest +/// 1-2-5 unit giving at most `max_intervals` intervals: +/// The data range [lo, hi] an axis covers, clamped to ±1e100; (0, 1) without finite values. +/// +/// - **Maximum:** `hi + (hi − lo)/20` (with `headroom`), rounded up to the unit. +/// - **Minimum:** zero when every value is ≥ 0 and `lo` is below 5/6 of `hi`; otherwise +/// `lo − (hi − lo)/20`, rounded down to the unit, so values bunched high get an axis that starts +/// near them. +/// - All-negative values mirror this: the axis ends at zero when `hi` is above 5/6 of `lo`. +/// Mixed signs get headroom at both ends. +fn axis_data_range(lo: f64, hi: f64) -> (f64, f64) { + if lo.is_finite() && hi.is_finite() { (lo.min(hi).clamp(-1e100, 1e100), lo.max(hi).clamp(-1e100, 1e100)) } else { (0.0, 1.0) } +} + +/// Office's automatic (min, max) of the data range [lo, hi]. "How chart axis limits are determined", +/// as Jon Peltier summarises it: 5 % of the data range past the data, and zero when the values don't +/// sit within the top sixth of their largest magnitude. A single value is measured from zero. +fn auto_limits(lo: f64, hi: f64, headroom: bool) -> (f64, f64) { + let flat = hi - lo <= 1e-12 * lo.abs().max(hi.abs()); + let pad = if !headroom { + 0.0 + } else if flat { + hi.abs() / 20.0 + } else { + (hi - lo) / 20.0 + }; + let auto_lo = if lo >= 0.0 && (flat || lo < hi * 5.0 / 6.0) { 0.0 } else { lo - pad }; + let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad }; + (auto_lo, auto_hi) +} + +/// The ends of a span too small to show: with one fixed limit on the far side of the data, the +/// automatic end gives way; otherwise the span is one `unit` wide. +fn flat_span_ends(fmin: Option, fmax: Option, lo: f64, hi: f64, unit: f64) -> (f64, f64) { + match (fmin, fmax) { + (None, Some(_)) => (if hi > 0.0 { 0.0 } else { hi - hi.abs().max(unit) }, hi), + (Some(_), None) if lo < 0.0 => (lo, 0.0), + _ => (lo, lo + unit), + } +} + +/// The major step: `major` when it gives at most 1000 intervals, else the smallest 1-2-5 step +/// that fits `n` intervals over [lo, hi]. +fn axis_step(major: Option, lo: f64, hi: f64, n: f64, unit: f64) -> f64 { + match major.filter(|m| *m > 0.0 && (hi - lo) / m <= 1000.0) { + Some(m) => m, + None => { + let mut step = 10f64.powf(((hi - lo) / n).log10().floor()); + // Subnormal spans underflow to a zero (or non-finite) step: fall back to the unit. + if !(step > 0.0 && step.is_finite()) { + step = unit; + } + let mut k = 0; + // 1, 2, 5, 10, 20… × the magnitude until the intervals fit. + while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 { + step *= if matches!(k % 3, 1) { 2.5 } else { 2.0 }; + k += 1; + } + step + } + } +} + +pub fn axis_scale( + lo: f64, + hi: f64, + fmin: Option, + fmax: Option, + major: Option, + max_intervals: usize, + headroom: bool, +) -> (f64, f64, f64) { + // Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite. + let fin = |v: Option| v.filter(|v| v.is_finite()).map(|v| v.clamp(-1e100, 1e100)); + let (fmin, fmax) = (fin(fmin), fin(fmax)); + let (lo, hi) = axis_data_range(lo, hi); + let (auto_lo, auto_hi) = auto_limits(lo, hi, headroom); + let (mut lo, mut hi) = (fmin.unwrap_or(auto_lo), fmax.unwrap_or(auto_hi)); + let mag = lo.abs().max(hi.abs()); + // Relative to the data's magnitude, so tiny values don't count as flat; all-zero falls back to 1. + let unit = if mag == 0.0 { 1.0 } else { mag }; + if hi - lo <= 1e-12 * mag { + (lo, hi) = flat_span_ends(fmin, fmax, lo, hi, unit); + } + let n = max_intervals.clamp(1, 50) as f64; + let step = axis_step(fin(major), lo, hi, n, unit); + let min = fmin.unwrap_or((lo / step + 1e-9).floor() * step); + let max = fmax.unwrap_or((hi / step - 1e-9).ceil() * step).max(min + step); + (min, max, step) +} + +/// "Nice" axis scale: (min, max, major step) covering [lo, hi]. +pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { + let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) }; + let data_mag = lo.abs().max(hi.abs()); + let span = if hi - lo <= 1e-12 * data_mag { data_mag } else { hi - lo }; + let span = if span == 0.0 { 1.0 } else { span }; + let raw = span / 5.0; + let mag = 10f64.powf(raw.log10().floor()); + let norm = raw / mag; + let step = if norm <= 1.0 { + 1.0 + } else if norm <= 2.0 { + 2.0 + } else if norm <= 2.5 { + 2.5 + } else if norm <= 5.0 { + 5.0 + } else { + 10.0 + } * mag; + // Subnormal spans underflow to a zero or NaN step: use the span itself. + let step = if step > 0.0 && step.is_finite() { step } else { span }; + let min = (lo / step).floor() * step; + let max = (hi / step).ceil() * step; + let max = if (max - min).abs() <= 1e-12 * data_mag { min + step } else { max }; + (min, max, step) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tiny_data_keeps_its_own_scale() { + let (min, max, step) = axis_scale(1e-13, 5e-13, None, None, None, 8, true); + assert!(min == 0.0 && (5e-13..1e-11).contains(&max), "{min} {max} {step}"); + assert!(step > 0.0 && step < 1e-11); + let (min, max, step) = nice_scale(1e-13, 5e-13); + assert!(min <= 1e-13 && (5e-13..1e-11).contains(&max) && step < 1e-11, "{min} {max} {step}"); + assert_eq!(axis_scale(0.0, 0.0, None, None, None, 8, true).1, 1.0); + } + + #[test] + fn subnormal_data_gives_a_finite_scale() { + let (min, max, step) = axis_scale(0.0, 5e-324, None, None, None, 8, true); + assert!(min.is_finite() && max.is_finite() && step.is_finite(), "{min} {max} {step}"); + assert!(step > 0.0 && max >= min, "{min} {max} {step}"); + let (min, max, step) = nice_scale(0.0, f64::from_bits(1)); + assert!(min.is_finite() && max.is_finite() && step > 0.0 && step.is_finite() && max > min, "{min} {max} {step}"); + } + + #[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))); + } + + /// #868: a series' own label setting wins; an unset series takes the chart-wide one. + #[test] + fn series_data_labels_override_the_chart_wide_setting() { + let mut c = Chart::sample(ChartType::Column); + c.data_labels = false; + let mut s = c.series[0].clone(); + assert!(!c.series_data_labels(&s), "unset series follows the chart"); + s.data_labels = Some(true); + assert!(c.series_data_labels(&s), "a series that asks for labels gets them"); + c.data_labels = true; + s.data_labels = Some(false); + assert!(!c.series_data_labels(&s), "an explicit series false hides them anyway"); + } + + #[test] + fn error_bar_ranges() { + let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; + let mut e = ErrorBars::default(); + assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0))); + e.val_type = "percentage".into(); + e.value = 20.0; + assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0))); + e.bar_type = "plus".into(); + assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0))); + e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() }; + assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5))); + e = ErrorBars { val_type: "stdDev".into(), ..Default::default() }; + let (lo, hi) = e.range(&vals, 0, 3.0).unwrap(); + assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9); + } + + #[test] + fn nice_scale_covers() { + let (a, b, s) = nice_scale(0.0, 5.1); + assert!(a <= 0.0 && b >= 5.1 && s > 0.0); + let (a, b, _) = nice_scale(f64::NAN, 3.0); + assert!(a.is_finite() && b.is_finite()); + let (a, b, _) = nice_scale(7.0, 7.0); + assert!(b > a); + } + + #[test] + fn automatic_axis_scales_like_powerpoint() { + // Weekly spend up to ~715 000 on a plot with room for 9 intervals: 0–800 000 by 100 000. + assert_eq!(axis_scale(0.0, 715_000.0, None, None, None, 9, true), (0.0, 800_000.0, 100_000.0)); + // Clicks up to 6 600: 0–7 000 by 1 000. + assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 9, true), (0.0, 7_000.0, 1_000.0)); + // Fewer intervals fit on a short axis. + assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 4, true), (0.0, 8_000.0, 2_000.0)); + // Negative data gets room below; fixed bounds and unit are kept. + let (min, max, _) = axis_scale(-30.0, 100.0, None, None, None, 8, true); + assert!(min <= -30.0 && max >= 105.0); + assert_eq!(axis_scale(0.0, 715_000.0, Some(100_000.0), Some(750_000.0), Some(50_000.0), 9, true), (100_000.0, 750_000.0, 50_000.0)); + // 100 % stacked stays at 0–1 without headroom. + assert_eq!(axis_scale(0.0, 1.0, None, None, None, 5, false), (0.0, 1.0, 0.2)); + // Nonsense never loops or divides by zero. + for (lo, hi) in [(f64::NAN, 1.0), (5.0, 5.0), (0.0, 0.0), (-1e300, 1e300)] { + let (a, b, s) = axis_scale(lo, hi, None, None, Some(0.0), 0, true); + assert!(a.is_finite() && b.is_finite() && s > 0.0 && b > a, "{lo} {hi}: {a} {b} {s}"); + } + } + + fn column(values: &[f64]) -> Chart { + let series = Series { name: "Sales".into(), values: values.iter().map(|v| Some(*v)).collect(), ..Default::default() }; + Chart { categories: (0..values.len()).map(|i| format!("C{i}")).collect(), series: vec![series], ..Default::default() } + } + + /// The primary axis of a chart with room for ten intervals. + fn scale(c: &Chart) -> (f64, f64, f64) { + c.axis_scale(false, 10).unwrap_or_default() + } + + /// Office's documented automatic limits (#144), with room for ten intervals. + #[test] + fn automatic_axis_matches_office() { + // #144: 42.1 / 18.7 / 6.3 → 0–45 in steps of 5, not 0–50 in steps of 10. + assert_eq!(scale(&column(&[42.1, 18.7, 6.3])), (0.0, 45.0, 5.0)); + // PowerPoint's default chart data (max 5): 0–6 in steps of 1. + let mut c = column(&[4.3, 2.5, 3.5, 4.5]); + c.series.push(Series { values: [2.4, 4.4, 1.8, 2.8].map(Some).to_vec(), ..Default::default() }); + c.series.push(Series { values: [2.0, 2.0, 3.0, 5.0].map(Some).to_vec(), ..Default::default() }); + assert_eq!(scale(&c), (0.0, 6.0, 1.0)); + // A maximum of 100 (or 10) gets 5 % headroom, which rounds up to the next major unit. + assert_eq!(scale(&column(&[100.0, 40.0])), (0.0, 120.0, 20.0)); + assert_eq!(scale(&column(&[10.0, 3.0])), (0.0, 12.0, 2.0)); + // Peltier's example: 2.95 + 2.95/20 ≈ 3.1 rounds up to 3.5. + assert_eq!(scale(&column(&[2.95, 0.0])), (0.0, 3.5, 0.5)); + } + + #[test] + fn automatic_axis_minimum() { + // Below 5/6 of the maximum the axis starts at zero; at or above it, a unit below + // min − (max − min)/20. + assert_eq!(scale(&column(&[59.0, 49.0])).0, 0.0); + assert_eq!(scale(&column(&[59.0, 50.0])), (48.0, 60.0, 2.0)); + assert_eq!(scale(&column(&[85.0, 92.0, 97.0])), (84.0, 98.0, 2.0)); + // All-negative data mirrors it: the axis ends at zero, or near the data. + assert_eq!(scale(&column(&[-42.1, -18.7, -6.3])), (-45.0, 0.0, 5.0)); + assert_eq!(scale(&column(&[-59.0, -49.0])).1, 0.0); + assert_eq!(scale(&column(&[-59.0, -50.0])), (-60.0, -48.0, 2.0)); + // Mixed signs get headroom at both ends. + assert_eq!(scale(&column(&[42.1, -18.7])), (-30.0, 50.0, 10.0)); + // Stacked bars build up from zero, so their axis keeps it. + let mut s = column(&[85.0, 92.0]); + s.kind = ChartType::StackedColumn; + assert_eq!(scale(&s).0, 0.0); + // A combo chart's secondary axis follows the same rule. + let mut c = Chart::sample(ChartType::Combo); + c.series.truncate(2); + c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; + c.series[1].kind = Some(ChartType::Line); + c.series[1].values = vec![Some(7_000.0), Some(7_400.0)]; + c.series[1].secondary_axis = true; + assert_eq!(c.axis_scale(false, 10), Some((0.0, 350_000.0, 50_000.0))); + assert_eq!(c.axis_scale(true, 10), Some((6_950.0, 7_450.0, 50.0))); + } + + #[test] + fn fixed_axis_limits_are_kept() { + let mut c = column(&[42.1, 18.7, 6.3]); + c.y_max = Some(50.0); + assert_eq!(scale(&c), (0.0, 50.0, 5.0)); + c.y_min = Some(10.0); + c.y_max = None; + assert_eq!(scale(&c), (10.0, 45.0, 5.0)); + // A fixed limit beyond the data: the automatic end gives way to it. + let mut c = column(&[85.0, 92.0, 97.0]); + c.y_max = Some(50.0); + assert_eq!(scale(&c), (0.0, 50.0, 5.0)); + let mut c = column(&[-50.0, -48.0]); + c.y_min = Some(-10.0); + assert_eq!(scale(&c), (-10.0, 0.0, 1.0)); + } + + #[test] + fn percent_axis_is_zero_to_one() { + let mut p = column(&[42.1, 18.7, 6.3]); + p.kind = ChartType::PercentColumn; + assert_eq!(scale(&p), (0.0, 1.0, 0.1)); + assert_eq!(p.axis_scale(false, 5), Some((0.0, 1.0, 0.2))); + } + + #[test] + fn degenerate_axes_are_finite() { + for (a, b) in [(0.0, 0.0), (7.0, 7.0), (-7.0, -7.0), (f64::NAN, 3.0), (f64::INFINITY, 1.0), (-f64::MAX, f64::MAX), (0.0, 1e-320)] { + let (min, max, step) = scale(&column(&[a, b])); + assert!(min.is_finite() && max > min && step > 0.0, "{a} {b}: {min} {max} {step}"); + } + let (min, max, _) = scale(&column(&[7.0, 7.0])); + assert!(min == 0.0 && max > 7.0); + assert_eq!(scale(&column(&[])), (0.0, 1.0, 0.1)); + for (fmin, fmax) in [(Some(5.0), Some(5.0)), (Some(5.0), Some(1.0)), (Some(f64::NAN), Some(f64::INFINITY)), (Some(-1e300), None)] { + let (a, b, s) = axis_scale(0.0, 1.0, fmin, fmax, None, 10, true); + assert!(a.is_finite() && b.is_finite() && b > a && s > 0.0, "{fmin:?} {fmax:?}: {a} {b} {s}"); + } + } + + #[test] + fn general_labels_keep_every_digit_the_data_has() { + // #324: labels were rounded to one decimal ("100000.0" for 99999.99). + for kind in [ChartType::Column, ChartType::Pie, ChartType::Doughnut, ChartType::Line, ChartType::Scatter] { + let c = Chart::sample(kind); + let s = &c.series[0]; + for (v, want) in + [(1220.82, "1220.82"), (99999.99, "99999.99"), (1500.05, "1500.05"), (1500.0, "1500"), (1234.567, "1234.567"), (999.99, "999.99")] + { + assert_eq!(c.data_label(s, v), want, "{kind:?}"); + } + } + // Small ranges keep distinct value-axis ticks (0.123 used to give 0, 0.0, 0.1, 0.1…). + let c = Chart::sample(ChartType::Column); + let ticks: Vec = (0..=5).map(|i| c.axis_label(false, f64::from(i) * 0.025)).collect(); + 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); + c.y_format = Some(r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#.into()); + c.lang = Some("ru-RU".into()); + assert_eq!(c.axis_label(false, 800_000.0), "800\u{a0}000 ₽"); + assert_eq!(c.axis_label(false, 0.0), "- ₽"); + // No format: General. + assert_eq!(c.axis_label(true, 2.5), "2,5"); + c.lang = None; + assert_eq!(c.axis_label(true, 2.5), "2.5"); + // Data labels: the series' format, else the axis'. + c.lang = Some("ru-RU".into()); + c.series[0].number_format = Some(r#"#,##0\ "₽""#.into()); + assert_eq!(c.data_label(&c.series[0], 516_600_000.0), "516\u{a0}600\u{a0}000 ₽"); + assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽"); + } + + #[test] + fn full_percent_axes_count_in_percent() { + let mut c = Chart::sample(ChartType::PercentColumn); + assert_eq!(c.axis_label(false, 0.5), "50%"); + c.y_format = Some("0.0%".into()); + assert_eq!(c.axis_label(false, 0.5), "50.0%"); + let c = Chart::sample(ChartType::Column); + assert_eq!(c.axis_label(false, 0.5), "0.5"); + } + + #[test] + fn combo_axis_ranges() { + // Spend in columns on the primary axis, clicks as a line on the secondary one. + let mut c = Chart::sample(ChartType::Combo); + c.series.truncate(2); + c.series[0].kind = Some(ChartType::Column); + c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; + c.series[1].kind = Some(ChartType::Line); + c.series[1].values = vec![Some(6_000.0), Some(7_400.0)]; + c.series[1].secondary_axis = true; + assert_eq!(c.axis_range(false), Some((0.0, 310_000.0))); + assert_eq!(c.axis_range(true), Some((0.0, 7_400.0))); + assert_eq!(c.series_kind(&c.series[1]), ChartType::Line); + // Without a secondary series there is no secondary axis; a non-combo chart never has one. + c.series[1].secondary_axis = false; + assert_eq!(c.axis_range(true), None); + let col = Chart::sample(ChartType::Column); + assert_eq!(col.axis_range(false), Some(col.value_range())); + assert_eq!(col.axis_range(true), None); + assert!(!col.on_secondary(&col.series[0])); + } + + #[test] + fn sample_ranges() { + let c = Chart::sample(ChartType::StackedColumn); + let (_, hi) = c.value_range(); + assert!(hi > 10.0); + assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); + } +} + +/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the +/// left, as Excel bins) and how many observations fall in it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bin { + pub lo: f64, + pub hi: f64, + pub count: usize, +} + +/// Bins a histogram has at most (a hostile bin size cannot make millions). +pub const MAX_BINS: usize = 1000; + +/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at +/// the furthest points within 1.5 IQR of the box, and the points beyond them. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxStats { + pub low: f64, + pub q1: f64, + pub median: f64, + pub q3: f64, + pub high: f64, + pub mean: f64, + pub outliers: Vec, +} + +impl Chart { + /// The first series' finite observations. + fn observations(&self) -> Vec { + self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default() + } + + /// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else + /// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations. + pub fn histogram_bins(&self) -> Vec { + let mut v = self.observations(); + if v.is_empty() { + return vec![]; + } + v.sort_by(f64::total_cmp); + let (lo, hi) = (v[0], v[v.len() - 1]); + let span = hi - lo; + let n = v.len() as f64; + let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) { + w + } else if let Some(k) = self.bin_count.filter(|k| *k > 0) { + span / f64::from(k) + } else { + let mean = v.iter().sum::() / n; + let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0).max(1.0)).sqrt(); + 3.49 * sd / n.cbrt() + }; + let fixed = self.bin_count.filter(|k| *k > 0).filter(|_| self.bin_size.filter(|w| w.is_finite() && *w > 0.0).is_none()); + let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { + 1 + } else if let Some(k) = fixed { + (k as usize).clamp(1, MAX_BINS) + } else { + ((span / width).ceil() as usize).clamp(1, MAX_BINS) + }; + let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) }; + let mut bins: Vec = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect(); + for x in v { + // (lo, hi] bins, the first one also holding the minimum. + let mut i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1); + // The division can round across an edge: settle on the bin whose stored bounds hold x. + while i > 0 && x <= bins[i - 1].hi { + i -= 1; + } + while i + 1 < count && x > bins[i].hi { + i += 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, + verbatim: false, + subtotals: vec![], + ..self.clone() + }; + let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default(); + let base = self.series.first().cloned().unwrap_or_default(); + v.series.push(Series { + name, + values: bins.iter().map(|b| Some(b.count as f64)).collect(), + kind: pareto.then_some(ChartType::Column), + number_format: None, + ..base + }); + if pareto { + let total = bins.iter().map(|b| b.count).sum::().max(1) as f64; + let mut run = 0.0; + v.series.push(Series { + name: "Cumulative %".into(), + values: bins + .iter() + .map(|b| { + run += b.count as f64; + Some(run / total) + }) + .collect(), + kind: Some(ChartType::Line), + secondary_axis: true, + ..Default::default() + }); + v.y2_min = Some(0.0); + v.y2_max = Some(1.0); + v.y2_major = Some(0.2); + v.y2_format = Some("0%".into()); + } + v.vary_colors = false; + v + } + + /// The box chart's groups (distinct category labels in order of appearance; one unnamed + /// group without categories) and, per series, each group's summary (None when it has no + /// points). + /// + /// Each value is put in its group's bucket once (groups are found by name in a map), and the + /// series x groups result is held to [`MAX_BOX_CELLS`]: groups past it are left out. + pub fn box_groups(&self) -> (Vec, Vec>>) { + let max_groups = (MAX_BOX_CELLS / self.series.len().max(1)).max(1); + let mut groups: Vec = Vec::new(); + let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for c in &self.categories { + if !index.contains_key(c.as_str()) && groups.len() < max_groups { + index.insert(c, groups.len()); + groups.push(c.clone()); + } + } + if groups.is_empty() { + groups.push(String::new()); + index.insert("", 0); + } + // A value past the categories belongs to the unnamed group, when there is one. + let past_end = index.get("").copied(); + let stats = self + .series + .iter() + .map(|s| { + let mut buckets: Vec> = vec![Vec::new(); groups.len()]; + for (i, v) in s.values.iter().enumerate() { + let Some(v) = v.filter(|v| v.is_finite()) else { continue }; + let g = match self.categories.get(i) { + Some(c) => index.get(c.as_str()).copied(), + None => past_end, + }; + if let Some(b) = g.and_then(|g| buckets.get_mut(g)) { + b.push(v); + } + } + buckets.into_iter().map(|pts| box_stats_by(pts, self.inclusive_quartiles)).collect() + }) + .collect(); + (groups, stats) + } +} + +/// Most series x group summaries [`Chart::box_groups`] makes. +pub const MAX_BOX_CELLS: usize = 1_000_000; + +/// Widens the (min, max) extent `ext` to include `v`. +fn widen(ext: &mut (f64, f64), v: f64) { + ext.0 = ext.0.min(v); + ext.1 = ext.1.max(v); +} + +/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends. +fn quantile_exc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64); + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = v[i.saturating_sub(1).min(n - 1)]; + let b = v[i.min(n - 1)]; + a + (b - a) * frac +} + +/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data. +fn quantile_inc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64); + let i = pos.floor() as usize; + let a = v[i.min(n - 1)]; + let b = v[(i + 1).min(n - 1)]; + a + (b - a) * (pos - i as f64) +} + +/// The summary of one group's points, with exclusive quartiles. +pub fn box_stats(pts: Vec) -> Option { + box_stats_by(pts, false) +} + +/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC. +pub fn box_stats_by(mut pts: Vec, inclusive: bool) -> Option { + if pts.is_empty() { + return None; + } + pts.sort_by(f64::total_cmp); + let q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) }; + let (q1, median, q3) = (q(0.25), q(0.5), q(0.75)); + let iqr = q3 - q1; + let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); + let low = inside.first().copied().unwrap_or(q1).min(q1); + let high = inside.last().copied().unwrap_or(q3).max(q3); + let mean = pts.iter().sum::() / pts.len() as f64; + let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect(); + Some(BoxStats { low, q1, median, q3, high, mean, outliers }) +} + +/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and +/// its children (none for a leaf, which names its category index). +#[derive(Clone, Debug, PartialEq)] +pub struct TreeNode { + pub name: String, + pub value: f64, + pub leaf: Option, + pub children: Vec, +} + +/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper). +pub const MAX_LEVELS: usize = 16; + +impl Chart { + /// The first series as a hierarchy: top-level nodes, each holding the consecutive + /// categories that share its label, down to the leaves. Without outer levels every + /// category is a top-level leaf. Negative and missing values count as zero. + pub fn category_tree(&self) -> Vec { + let Some(s) = self.series.first() else { return vec![] }; + let n = self.categories.len().max(s.values.len()); + let levels: Vec<&Vec> = self.category_levels.iter().take(MAX_LEVELS).collect(); + // Each leaf's path from the top level down, blanks filled from the row above. + // Labels are shared, not copied: an outer label given once stands for every leaf under it. + use std::rc::Rc; + let empty: Rc = Rc::from(""); + let mut last: Vec> = vec![empty.clone(); levels.len()]; + let paths: Vec>> = (0..n) + .map(|i| { + let mut path = Vec::with_capacity(levels.len() + 1); + for (d, lv) in levels.iter().enumerate().rev() { + let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); + if !l.is_empty() { + last[d] = Rc::from(l); + // A new outer label starts fresh inner groups. + for inner in last.iter_mut().take(d) { + *inner = empty.clone(); + } + } + path.push(last[d].clone()); + } + path.push(self.categories.get(i).map_or_else(|| empty.clone(), |c| Rc::from(c.as_str()))); + 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, &[std::rc::Rc])], value: &dyn Fn(usize) -> f64) -> Vec { + let mut out: Vec = Vec::new(); + let mut i = 0; + while i < rows.len() { + let (idx, path) = rows[i]; + if path.len() <= 1 { + out.push(TreeNode { + name: path.first().map(|p| p.to_string()).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, &[std::rc::Rc])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); + let children = build(&sub, value); + out.push(TreeNode { name: head.to_string(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); + i = j; + } + out + } + let rows: Vec<(usize, &[Rc])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); + build(&rows, &value) + } +} + +#[cfg(test)] +mod chartex_tests { + use super::*; + + fn obs(v: &[f64]) -> Chart { + Chart { + kind: ChartType::Histogram, + series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }], + ..Default::default() + } + } + + #[test] + fn histogram_bins_by_count_size_and_scott() { + let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]); + c.bin_count = Some(4); + let b = c.histogram_bins(); + assert_eq!(b.len(), 4); + assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0)); + // (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9. + assert_eq!(b.iter().map(|b| b.count).collect::>(), vec![4, 2, 0, 1]); + c.bin_count = None; + c.bin_size = Some(5.0); + assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::>(), vec![6, 1]); + c.bin_size = None; + let auto = c.histogram_bins(); + assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::() == 7); + // A tiny bin size is capped; one value gives one bin. + c.bin_size = Some(1e-12); + assert!(c.histogram_bins().len() <= MAX_BINS); + assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]); + assert!(obs(&[]).histogram_bins().is_empty()); + } + + #[test] + fn histogram_fixed_bin_count_is_exact_despite_float_rounding() { + let mut c = obs(&[0.0, 1.0, 2.1]); + c.bin_count = Some(7); + assert_eq!(c.histogram_bins().len(), 7); + } + + #[test] + fn histogram_value_on_a_bin_edge_counts_in_that_bin() { + let mut c = obs(&[0.0, 2.1, 3.0]); + c.bin_size = Some(0.3); + let bins = c.histogram_bins(); + let own = bins.iter().find(|b| b.lo < 2.1 && 2.1 <= b.hi).expect("a bin holds 2.1"); + assert_eq!(own.count, 1, "2.1 must be counted in ({}, {}]", own.lo, own.hi); + assert_eq!(bins.iter().map(|b| b.count).sum::(), 3); + } + + #[test] + fn pareto_view_sorts_bins_and_adds_the_cumulative_line() { + let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]); + c.kind = ChartType::Pareto; + c.bin_count = Some(3); + let v = c.histogram_view(); + assert_eq!(v.kind, ChartType::Combo); + assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]); + let cum = &v.series[1]; + assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line)); + assert_eq!(cum.values.last(), Some(&Some(1.0))); + assert_eq!(v.categories[0], "(3.67, 6.33]"); + } + + #[test] + fn box_stats_use_exclusive_quartiles_and_fence_outliers() { + let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap(); + // QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75. + assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75)); + assert_eq!(b.outliers, vec![100.0]); + assert_eq!((b.low, b.high), (1.0, 7.0)); + assert!(box_stats(vec![]).is_none()); + let one = box_stats(vec![5.0]).unwrap(); + assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); + } + + #[test] + fn inclusive_quartiles_follow_quartile_inc() { + let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap(); + // QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based). + assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25)); + } + + #[test] + fn category_tree_groups_runs_and_fills_blank_parents() { + let mut c = Chart::sample(ChartType::Treemap); + let t = c.category_tree(); + assert_eq!(t.iter().map(|n| n.name.as_str()).collect::>(), ["Q1", "Q2", "Q3"]); + assert_eq!(t[0].children.len(), 3); + assert_eq!(t[0].children[1].leaf, Some(1)); + assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9); + // Blank parents continue the label above; two outer levels nest. + c.category_levels = vec![ + vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()], + vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()], + ]; + let t = c.category_tree(); + assert_eq!(t.len(), 2); + assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::>(), ["a", "b"]); + assert_eq!(t[1].children[0].children.len(), 4); + // No levels: every category is a leaf. + c.category_levels.clear(); + assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); + } + + #[test] + fn large_box_hierarchy_and_stacked_charts_take_linear_work() { + // 50,000 distinct categories in one series: grouped through a map, one pass. + let n = 50_000; + let mut c = Chart::sample(ChartType::BoxWhisker); + c.series.truncate(1); + c.categories = (0..n).map(|i| format!("g{i}")).collect(); + c.series[0].values = (0..n).map(|i| Some(i as f64)).collect(); + let (groups, stats) = c.box_groups(); + assert_eq!((groups.len(), stats[0].len()), (n, n)); + assert!(stats[0].iter().all(Option::is_some)); + // 2,000 series cap the groups so the result stays within MAX_BOX_CELLS. + c.series = vec![c.series[0].clone(); 2000]; + let (groups, stats) = c.box_groups(); + assert_eq!(groups.len(), MAX_BOX_CELLS / 2000); + assert!(stats.iter().all(|s| s.len() == groups.len())); + // One long outer label over 50,000 leaves is shared, not copied per leaf. + let mut t = Chart::sample(ChartType::Treemap); + t.series.truncate(1); + t.categories = (0..n).map(|i| format!("c{i}")).collect(); + t.series[0].values = (0..n).map(|_| Some(1.0)).collect(); + let mut outer = vec![String::new(); n]; + outer[0] = "o".repeat(1 << 16); + t.category_levels = vec![outer]; + let tree = t.category_tree(); + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].children.len(), n); + // Stacked sums over many sparse series. + let mut st = Chart::sample(ChartType::StackedBar); + st.categories = (0..n).map(|i| i.to_string()).collect(); + st.series = (0..2000).map(|k| Series { values: vec![Some(1.0); if k == 0 { n } else { 1 }], ..Default::default() }).collect(); + assert!(st.value_range().1 >= 2000.0, "{:?}", st.value_range()); + } + + #[test] + fn box_groups_follow_category_labels() { + let c = Chart::sample(ChartType::BoxWhisker); + let (groups, stats) = c.box_groups(); + assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]); + assert_eq!(stats.len(), 2); + assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); + } +} diff --git a/crates/drawingml/src/diagram.rs b/crates/drawingml/src/diagram.rs new file mode 100644 index 000000000..30db71e88 --- /dev/null +++ b/crates/drawingml/src/diagram.rs @@ -0,0 +1,53 @@ +//! SmartArt (`dgm:`): a diagram's layout and its outline of nodes. + +use serde::{Deserialize, Serialize}; + +use crate::MediaId; + +/// A SmartArt diagram's data: its layout and its outline of nodes (the Text Pane). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Diagram { + /// The layout: list, blockList, hBulletList, process, chevron, verticalProcess, stepUp, cycle, + /// blockCycle, radial, hierarchy, pyramid, invertedPyramid, matrix or titledMatrix. + pub kind: String, + pub nodes: Vec, + /// SmartArt Style: `flat` (default), `subtle`, `intense` or `outline`. + #[serde(skip_serializing_if = "String::is_empty")] + pub style: String, + /// Change Colors: `colorful` (default: each shape its own accent) or `accent1`…`accent6`. + #[serde(skip_serializing_if = "String::is_empty")] + pub colors: String, + /// The group's shapes are the file's drawing, not generated from `nodes`: text typed into them + /// isn't mapped back to nodes (the first outline edit regenerates them). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub imported: bool, + /// PowerPoint's layout id (`layoutDef@uniqueId`) the diagram came with. + #[serde(skip_serializing_if = "String::is_empty")] + pub layout_id: String, + /// The file's own layout, quick style and colour definitions, written back unchanged while the + /// layout isn't switched. + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_xml: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub style_xml: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub colors_xml: Option, +} + +/// One line of the Text Pane: its text and outline level (0 = top level). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DiagramNode { + pub text: String, + pub level: u8, + /// The picture shown in the node's picture placeholder (picture layouts). + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option, + /// An assistant (organisation charts): drawn beside its parent, off the row of its siblings. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub assistant: bool, + /// The job title under the name (Name and Title Organization Chart). + #[serde(skip_serializing_if = "String::is_empty")] + pub title: String, +} diff --git a/crates/drawingml/src/lib.rs b/crates/drawingml/src/lib.rs index 0f847c990..47d6ee107 100644 --- a/crates/drawingml/src/lib.rs +++ b/crates/drawingml/src/lib.rs @@ -9,19 +9,57 @@ //! - [`preset`]: outlines of the preset shapes (`a:prstGeom`), by their file names. //! - [`custgeom`]: custom geometry (`a:custGeom`), with the shape guide formulas. //! - [`warp`]: preset text warps (`a:prstTxWarp`). +//! - The model, without readers and writers yet: [`style`] (colour references, fills, lines, +//! effects), [`text`] (text bodies, paragraphs, runs), [`table`], [`chart`] with [`numfmt`] +//! (number format codes) and [`diagram`] (SmartArt outlines). Media are named by [`MediaId`]; +//! the bytes stay with the caller. //! //! Nothing here opens a package: readers take a [`craft_xml::El`] and writers a //! [`craft_xml::Writer`], so the same code serves a slide, a document or a worksheet drawing. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod chart; pub mod color; pub mod custgeom; +pub mod diagram; +pub mod numfmt; pub mod preset; +pub mod style; +pub mod table; +pub mod text; +mod trendline; pub mod warp; use kurbo::{Affine, Point, Rect, Vec2}; use serde::{Deserialize, Serialize}; +pub(crate) fn yes() -> bool { + true +} + +macro_rules! id_type { + ($(#[$doc:meta])* $name:ident) => { + $(#[$doc])* + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub u32); + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} +id_type!( + /// A picture, sound or other embedded file, by the id its owner gave it. The bytes stay with + /// the owner (a package part in a file). + MediaId +); +id_type!( + /// The target of a link to a slide. Only presentations have them. + SlideId +); + /// EMU per point. pub const EMU_PER_PT: f64 = 12_700.0; /// EMU per inch. diff --git a/crates/drawingml/src/numfmt.rs b/crates/drawingml/src/numfmt.rs new file mode 100644 index 000000000..b5db9667c --- /dev/null +++ b/crates/drawingml/src/numfmt.rs @@ -0,0 +1,415 @@ +//! Number formats: Excel format codes (`#,##0`, `0.0%`, `_-* # ##0 "₽"_-;…`, `General`) as chart +//! axes and data labels show them, in the chart's language (digit grouping and decimal mark). + +/// Digit grouping and decimal mark for a language tag (`ru-RU`, `de-DE`, `en-US`). +fn marks(lang: Option<&str>) -> (char, char) { + let l = lang.unwrap_or("en").split(['-', '_']).next().unwrap_or("en").to_ascii_lowercase(); + match l.as_str() { + // Space-grouped, comma decimal. + "ru" | "uk" | "be" | "kk" | "fr" | "pl" | "cs" | "sk" | "sv" | "fi" | "nb" | "nn" | "no" | "bg" | "lt" | "lv" | "et" | "hu" | "uz" | "hy" + | "ka" | "az" => ('\u{a0}', ','), + // Dot-grouped, comma decimal. + "de" | "es" | "it" | "pt" | "nl" | "da" | "tr" | "el" | "ro" | "id" | "sr" | "hr" | "sl" | "vi" => ('.', ','), + _ => (',', '.'), + } +} + +#[derive(Debug, PartialEq)] +enum Tok { + /// Text shown as is. + Lit(String), + /// A digit placeholder: `0`, `#` or `?`. + Digit(char), + Dot, + Comma, + Percent, +} + +/// A section's tokens (`[$₽-419]` gives its symbol; colours, conditions and locales drop out). +fn tokens(section: &str) -> Vec { + let mut out = vec![]; + let mut it = section.chars().peekable(); + while let Some(c) = it.next() { + match c { + '"' => { + let mut s = String::new(); + for d in it.by_ref() { + if d == '"' { + break; + } + s.push(d); + } + out.push(Tok::Lit(s)); + } + '\\' => { + if let Some(d) = it.next() { + out.push(Tok::Lit(d.to_string())); + } + } + // Padding the width of the next character: a space. + '_' => { + it.next(); + out.push(Tok::Lit(" ".into())); + } + // Repeat-to-fill: nothing on an axis. + '*' => { + it.next(); + } + '[' => { + let mut s = String::new(); + for d in it.by_ref() { + if d == ']' { + break; + } + s.push(d); + } + if let Some(sym) = s.strip_prefix('$') { + let sym = sym.split('-').next().unwrap_or(""); + if !sym.is_empty() { + out.push(Tok::Lit(sym.to_string())); + } + } + } + '0' | '#' | '?' => out.push(Tok::Digit(c)), + '.' => out.push(Tok::Dot), + ',' => out.push(Tok::Comma), + '%' => out.push(Tok::Percent), + '@' => {} + _ => out.push(Tok::Lit(c.to_string())), + } + } + out +} + +/// The format's sections split at `;` (outside quotes, brackets and escapes). +fn sections(code: &str) -> Vec<&str> { + let mut out = vec![]; + let (mut start, mut quoted, mut bracket, mut escaped) = (0, false, false, false); + for (i, c) in code.char_indices() { + if escaped { + escaped = false; + continue; + } + match c { + '\\' if !quoted => escaped = true, + '"' => quoted = !quoted, + '[' if !quoted => bracket = true, + ']' if !quoted => bracket = false, + ';' if !quoted && !bracket => { + out.push(code.get(start..i).unwrap_or("")); + start = i + 1; + } + _ => {} + } + } + out.push(code.get(start..).unwrap_or("")); + out +} + +/// `v` in the General format: up to 10 significant digits, no grouping. +fn general(v: f64, dec: char) -> String { + if v == 0.0 { + return "0".into(); + } + let a = v.abs(); + let s = if !(1e-9..1e11).contains(&a) { + let s = format!("{v:.5E}"); + match s.split_once('E') { + Some((m, e)) => { + let m = if m.contains('.') { m.trim_end_matches('0').trim_end_matches('.') } else { m }; + let e: i32 = e.parse().unwrap_or(0); + format!("{m}E{}{:02}", if e < 0 { '-' } else { '+' }, e.abs()) + } + None => s, + } + } else { + let digits = (10 - (a.log10().floor() as i32 + 1)).clamp(0, 10) as usize; + let s = format!("{v:.digits$}"); + if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s } + }; + s.replace('.', &dec.to_string()) +} + +/// Group an integer's digits in threes with `sep`. +fn group(digits: &str, sep: char) -> String { + let n = digits.chars().count(); + let mut out = String::new(); + for (i, d) in digits.chars().enumerate() { + if i > 0 && (n - i).is_multiple_of(3) { + out.push(sep); + } + out.push(d); + } + out +} + +/// Longest number format applied, as in Excel (255 characters); a longer one formats as +/// General, so a chart cannot make every label expand a huge format. +pub const MAX_CODE_LEN: usize = 255; + +/// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text +/// section of a format doesn't apply). +pub fn format(code: &str, v: f64, lang: Option<&str>) -> String { + let code = if code.len() > MAX_CODE_LEN * 4 || code.chars().count() > MAX_CODE_LEN { "General" } else { code }; + let (sep, dec) = marks(lang); + if !v.is_finite() { + return String::new(); + } + let secs = sections(code); + // The section for the value's sign; a negative or zero section shows the value unsigned. + let (sec, v, minus) = match (secs.as_slice(), v) { + ([_, neg, ..], v) if v < 0.0 => (*neg, -v, false), + ([_, _, zero, ..], v) if v == 0.0 => (*zero, v, false), + ([pos, ..], v) => (*pos, v.abs(), v < 0.0), + ([], v) => ("General", v.abs(), v < 0.0), + }; + if sec.trim().eq_ignore_ascii_case("general") || (secs.len() == 1 && sec.trim().is_empty()) { + let s = general(v, dec); + return if minus { format!("-{s}") } else { s }; + } + let toks = tokens(sec); + let first = toks.iter().position(|t| matches!(t, Tok::Digit(_))); + let last = toks.iter().rposition(|t| matches!(t, Tok::Digit(_))); + let (Some(first), Some(last)) = (first, last) else { + // No digits: just the section's text ("-" for zero, a label). + let s: String = toks.iter().filter_map(|t| if let Tok::Lit(s) = t { Some(s.as_str()) } else { None }).collect(); + return s.trim().to_string(); + }; + let out = sci(&toks, v, sep, dec).unwrap_or_else(|| fixed(&toks, first, last, v, sep, dec)); + let out = out.trim(); + if minus && !out.is_empty() { format!("-{out}") } else { out.to_string() } +} + +/// The text of the literal and percent tokens in `ts`. +fn lits(ts: &[Tok]) -> String { + ts.iter() + .filter_map(|t| match t { + Tok::Lit(s) => Some(s.clone()), + Tok::Percent => Some("%".into()), + _ => None, + }) + .collect() +} + +/// Scientific notation (`0.00E+00`, `##0.0E-0`): the mantissa takes the placeholders before the `E`, +/// the exponent's sign and minimum width come from what follows it; with several integer +/// placeholders the exponent is a multiple of their count (engineering notation). `None` if the +/// section has no exponent part. +fn sci(toks: &[Tok], v: f64, sep: char, dec: char) -> Option { + let e = toks.iter().position(|t| matches!(t, Tok::Lit(s) if s.as_str() == "E" || s.as_str() == "e"))?; + let e_char = if let Some(Tok::Lit(s)) = toks.get(e) { s.as_str() } else { "E" }; + let plus = match toks.get(e + 1)? { + Tok::Lit(s) if s.as_str() == "+" => true, + Tok::Lit(s) if s.as_str() == "-" => false, + _ => return None, + }; + let mant = toks.get(..e)?; + let mfirst = mant.iter().position(|t| matches!(t, Tok::Digit(_)))?; + let mlast = mant.iter().rposition(|t| matches!(t, Tok::Digit(_)))?; + let exp_toks = toks.get(e + 2..)?; + let exp_len = exp_toks.iter().take_while(|t| matches!(t, Tok::Digit(_))).count(); + if exp_len == 0 { + return None; + } + let width = exp_toks.iter().take(exp_len).filter(|t| **t == Tok::Digit('0')).count(); + let rest = exp_toks.get(exp_len..).unwrap_or(&[]); + let mant_frac = mant.iter().skip_while(|t| **t != Tok::Dot).filter(|t| matches!(t, Tok::Digit(_))).count(); + let (mut m, mut exp) = (0.0, 0i32); + if v != 0.0 { + exp = v.log10().floor() as i32; + m = if exp < -300 { + // 10^exp underflows for subnormals; scale in two steps. + v * 1e300 / 10f64.powi(exp + 300) + } else { + v / 10f64.powi(exp) + }; + if !m.is_finite() { + m = 0.0; + exp = 0; + } + if m >= 10.0 { + m /= 10.0; + exp += 1; + } else if m < 1.0 { + m *= 10.0; + exp -= 1; + } + // Rounding the mantissa to its digits can reach 10 (9.999 as `0.00`). + if format!("{m:.mant_frac$}").starts_with("10") { + m /= 10.0; + exp += 1; + } + } + let step = mant.iter().take_while(|t| **t != Tok::Dot).filter(|t| matches!(t, Tok::Digit(_))).count().max(1) as i32; + let shift = exp.rem_euclid(step); + exp -= shift; + m *= 10f64.powi(shift); + let mantissa = fixed(mant, mfirst, mlast, m, sep, dec); + let sign = if exp < 0 { + "-" + } else if plus { + "+" + } else { + "" + }; + let digits = exp.unsigned_abs().to_string(); + let pad = "0".repeat(width.saturating_sub(digits.len())); + Some(format!("{mantissa}{e_char}{sign}{pad}{digits}{}", lits(rest))) +} + +/// `v` (not negative) in the fixed-point format whose tokens are `toks`, with its digits from +/// `first` to `last`. +fn fixed(toks: &[Tok], first: usize, last: usize, v: f64, sep: char, dec: char) -> String { + let percent = toks.contains(&Tok::Percent); + let number = toks.get(first..=last).unwrap_or(&[]); + let dot = number.iter().position(|t| *t == Tok::Dot); + let (int_part, frac_part) = match dot { + Some(d) => (number.get(..d).unwrap_or(&[]), number.get(d + 1..).unwrap_or(&[])), + None => (number, &[][..]), + }; + // Grouping: a comma (or the locale's space, written literally) between integer digits. + let grouping = int_part.iter().any(|t| *t == Tok::Comma || matches!(t, Tok::Lit(s) if s.trim().is_empty())); + // Commas right after the last digit scale by a thousand each (`#,##0,` = thousands). + let after = toks.get(last + 1..).unwrap_or(&[]); + let scale_commas = after.iter().take_while(|t| **t == Tok::Comma).count(); + let min_int = int_part.iter().filter(|t| **t == Tok::Digit('0')).count(); + let frac_digits: Vec = frac_part.iter().filter_map(|t| if let Tok::Digit(c) = t { Some(*c) } else { None }).collect(); + let max_frac = frac_digits.len(); + let min_frac = frac_digits.iter().filter(|c| **c == '0').count(); + let mut x = v * if percent { 100.0 } else { 1.0 } / 1000f64.powi(scale_commas as i32); + if !x.is_finite() { + x = 0.0; + } + let s = format!("{x:.max_frac$}"); + let (ip, fp) = s.split_once('.').unwrap_or((s.as_str(), "")); + let mut fp = fp.to_string(); + while fp.len() > min_frac && fp.ends_with('0') { + fp.pop(); + } + let ip = ip.trim_start_matches('0'); + let ip = if ip.len() < min_int { format!("{}{ip}", "0".repeat(min_int - ip.len())) } else { ip.to_string() }; + let mut num = if grouping { group(&ip, sep) } else { ip }; + if !fp.is_empty() { + num.push(dec); + num.push_str(&fp); + } + let before = lits(toks.get(..first).unwrap_or(&[])); + let after = lits(toks.get(last + 1..).unwrap_or(&[])); + format!("{before}{num}{after}") +} + +#[cfg(test)] +mod tests { + use super::*; + + const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#; + + #[test] + fn an_overlong_format_is_general() { + let code = "0".repeat(100_000); + assert_eq!(format(&code, 12.5, None), format("General", 12.5, None)); + let ok = "0".repeat(MAX_CODE_LEN); + assert_eq!(format(&ok, 7.0, None).len(), MAX_CODE_LEN); + } + + #[test] + fn scientific_subnormal() { + assert_eq!(format("0.00E+00", 5e-324, None), "4.94E-324"); + assert_eq!(format("0.00E+00", f64::MIN_POSITIVE, None), "2.23E-308"); + } + + #[test] + fn russian_accounting_roubles() { + assert_eq!(format(RUB, 800_000.0, Some("ru-RU")), "800\u{a0}000 ₽"); + assert_eq!(format(RUB, 1_234_567.0, Some("ru-RU")), "1\u{a0}234\u{a0}567 ₽"); + assert_eq!(format(RUB, -5_000.0, Some("ru-RU")), "-5\u{a0}000 ₽"); + assert_eq!(format(RUB, 0.0, Some("ru-RU")), "- ₽"); + } + + #[test] + fn common_codes() { + assert_eq!(format("#,##0", 7000.0, Some("ru-RU")), "7\u{a0}000"); + assert_eq!(format("#,##0", 7000.0, Some("en-US")), "7,000"); + assert_eq!(format("#,##0", 7000.0, Some("de-DE")), "7.000"); + assert_eq!(format("0.0%", 0.125, Some("ru-RU")), "12,5%"); + assert_eq!(format("0.0%", 0.125, None), "12.5%"); + assert_eq!(format("0%", 0.0, None), "0%"); + assert_eq!(format("General", 2.5, Some("ru-RU")), "2,5"); + assert_eq!(format("General", 400_000.0, Some("ru-RU")), "400000"); + assert_eq!(format("General", 1.5e12, None), "1.5E+12"); + assert_eq!(format("General", 1e-10, None), "1E-10"); + assert_eq!(format("General", -2.5e-12, None), "-2.5E-12"); + assert_eq!(format("General", 1e100, None), "1E+100"); + assert_eq!(format("0.00", 1.23456, None), "1.23"); + assert_eq!(format("#,##0.0#", 1234.5, None), "1,234.5"); + assert_eq!(format("#,##0,", 12_345_678.0, None), "12,346"); + assert_eq!(format("[$₽-419]#,##0", 1500.0, Some("ru-RU")), "₽1\u{a0}500"); + assert_eq!(format("0;[Red]-0", -3.0, None), "-3"); + assert_eq!(format("0;0", -3.0, None), "3"); + assert_eq!(format("0", -3.0, None), "-3"); + assert_eq!(format("\"x\"0", 5.0, None), "x5"); + } + + #[test] + fn trailing_commas_with_decimals() { + assert_eq!(format("0.0,,", 15_000_000.0, Some("en-US")), "15.0"); + assert_eq!(format("0.00,\"K\"", 15_000.0, Some("en-US")), "15.00K"); + } + + #[test] + fn explicit_empty_sections_hide_their_values() { + for v in [3.0, -3.0, 0.0, -0.0] { + assert_eq!(format(";;;", v, None), "", "all sections empty: {v}"); + } + for (code, positive, negative, zero) in [ + (";0;0", "", "3", "0"), + ("0;;0", "3", "", "0"), + ("0;-0;", "3", "-3", ""), + (";0", "", "3", ""), + (r#"";"0;;"#, ";3", "", ""), + (r"\;0;;", ";3", "", ""), + ] { + assert_eq!(format(code, 3.0, None), positive, "positive: {code}"); + assert_eq!(format(code, -3.0, None), negative, "negative: {code}"); + assert_eq!(format(code, 0.0, None), zero, "zero: {code}"); + } + assert_eq!(format("0;-0;", -0.0, None), ""); + } + + #[test] + fn blank_whole_codes_and_general_keep_their_fallback() { + for code in ["", " \t", "General", " general "] { + assert_eq!(format(code, 2.5, None), "2.5", "{code:?}"); + assert_eq!(format(code, -2.5, None), "-2.5", "{code:?}"); + assert_eq!(format(code, 0.0, None), "0", "{code:?}"); + assert_eq!(format(code, 2.5, Some("de-DE")), "2,5", "{code:?}"); + } + } + + #[test] + fn scientific_formats() { + assert_eq!(format("0.00E+00", 1234.0, None), "1.23E+03"); + assert_eq!(format("0.00E+00", 1234.0, Some("ru-RU")), "1,23E+03"); + assert_eq!(format("0.00E+00", 0.00012, None), "1.20E-04"); + assert_eq!(format("0.00E+00", -1234.0, None), "-1.23E+03"); + assert_eq!(format("0.00E+00", 0.0, None), "0.00E+00"); + assert_eq!(format("0.0E+000", 5e10, None), "5.0E+010"); + assert_eq!(format("0.0E-0", 1234.0, None), "1.2E3"); + assert_eq!(format("0.0E-0", 0.0012, None), "1.2E-3"); + assert_eq!(format("0E+00", 9999.0, None), "1E+04"); + assert_eq!(format("0.00E+00", 9.999, None), "1.00E+01"); + assert_eq!(format("0.00e+00", 1234.0, None), "1.23e+03"); + assert_eq!(format("##0.0E+0", 12_345.0, None), "12.3E+3"); + assert_eq!(format("##0.0E+0", 0.012, None), "12.0E-3"); + assert_eq!(format("##0.0E+0", 999.96, None), "1.0E+3"); + } + + #[test] + fn never_panics() { + for code in ["", ";", ";;;", "\"", "[", "\\", "0.", ".", "#,", "%", "_", "*", "@", "0E+00", "[$", "\"unterminated"] { + for v in [0.0, -1.5, 1e300, f64::NAN, f64::INFINITY, 1e-300] { + let _ = format(code, v, Some("ru")); + } + } + } +} diff --git a/crates/drawingml/src/style.rs b/crates/drawingml/src/style.rs new file mode 100644 index 000000000..915214de5 --- /dev/null +++ b/crates/drawingml/src/style.rs @@ -0,0 +1,572 @@ +//! Colours, fills, lines and effects. + +use crate::color::{ColorScheme, ColorTransform, Rgba, SchemeSlot}; +use serde::{Deserialize, Serialize}; + +use crate::MediaId; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum ColorBase { + Rgb { + rgb: Rgba, + }, + Scheme { + slot: SchemeSlot, + }, + Preset { + name: String, + }, + /// A system colour (`windowText`…) with the value it had when saved. + System { + name: String, + last: Rgba, + }, +} + +/// A colour as written in a document: a base (RGB, theme slot…) plus transforms. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ColorRef { + pub base: ColorBase, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mods: Vec, +} + +impl ColorRef { + pub fn rgb(c: Rgba) -> Self { + ColorRef { base: ColorBase::Rgb { rgb: c }, mods: vec![] } + } + pub fn scheme(slot: SchemeSlot) -> Self { + ColorRef { base: ColorBase::Scheme { slot }, mods: vec![] } + } + pub fn with(mut self, m: ColorTransform) -> Self { + self.mods.push(m); + self + } + /// Resolve against a colour scheme; `ph` substitutes `phClr` (style matrix colour). + pub fn resolve(&self, scheme: &ColorScheme, ph: Option) -> Rgba { + let base = match &self.base { + ColorBase::Rgb { rgb } => *rgb, + ColorBase::Scheme { slot: SchemeSlot::PhClr } => ph.unwrap_or_else(|| scheme.get(SchemeSlot::Accent1)), + ColorBase::Scheme { slot } => scheme.get(*slot), + ColorBase::Preset { name } => crate::color::preset(name).unwrap_or(Rgba::BLACK), + ColorBase::System { last, .. } => *last, + }; + crate::color::apply(base, &self.mods) + } + /// Alpha (0–1) from the `alpha`, `alphaMod` and `alphaOff` transforms, applied in order. + pub fn alpha(&self) -> f64 { + self.mods.iter().fold(1.0, |a, m| match m { + ColorTransform::Alpha(v) => (*v as f64 / 100_000.0).clamp(0.0, 1.0), + ColorTransform::AlphaMod(v) => (a * *v as f64 / 100_000.0).clamp(0.0, 1.0), + ColorTransform::AlphaOff(v) => (a + *v as f64 / 100_000.0).clamp(0.0, 1.0), + _ => a, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GradientStop { + /// 0–1. + pub pos: f64, + pub color: ColorRef, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GradientFlip { + None, + X, + Y, + Xy, +} + +impl GradientFlip { + pub fn xml(self) -> &'static str { + match self { + GradientFlip::None => "none", + GradientFlip::X => "x", + GradientFlip::Y => "y", + GradientFlip::Xy => "xy", + } + } + + pub fn from_xml(value: &str) -> Option { + match value { + "none" => Some(GradientFlip::None), + "x" => Some(GradientFlip::X), + "y" => Some(GradientFlip::Y), + "xy" => Some(GradientFlip::Xy), + _ => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum GradientShape { + /// Angle in degrees clockwise from left→right. + Linear { + angle: f64, + #[serde(default)] + scaled: bool, + }, + /// `circle`, `rect` or `shape` path gradient; `focus` is the fill-to rect as fractions (l, t, r, b). + Path { path: String, focus: [f64; 4] }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Gradient { + pub stops: Vec, + pub shape: GradientShape, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flip: Option, + #[serde(default = "crate::yes")] + pub rotate_with_shape: bool, + /// `a:tileRect`: the box the gradient is laid out in, as insets from the shape's box + /// (fractions; negative reaches outside it). PowerPoint's "from corner" radial presets use it + /// to centre the gradient on the corner. + #[serde(default, skip_serializing_if = "is_zero4")] + pub tile: [f64; 4], +} + +fn is_zero4(v: &[f64; 4]) -> bool { + v.iter().all(|x| *x == 0.0) +} + +/// How a picture fills its box: stretched into a (fractional) inset rect, or tiled. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum PictureMode { + Stretch { + #[serde(default)] + fill_rect: [f64; 4], + }, + Tile { + tx: f64, + ty: f64, + sx: f64, + sy: f64, + #[serde(default)] + flip: String, + #[serde(default)] + align: String, + }, +} + +impl Default for PictureMode { + fn default() -> Self { + PictureMode::Stretch { fill_rect: [0.0; 4] } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PictureFill { + pub media: MediaId, + /// Source crop as fractions of the image (left, top, right, bottom); negative values pad. + pub crop: [f64; 4], + pub mode: PictureMode, + /// 0–1 opacity of the picture. + pub alpha: Option, + pub adjust: PictureAdjust, + /// `rotWithShape="0"`: the picture keeps to the slide's axes when its shape is rotated or + /// flipped. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub upright: bool, + /// An artistic effect (Picture Format ▸ Artistic Effects). `media` is then the processed + /// picture; the original is kept to change or remove the effect. + #[serde(skip_serializing_if = "Option::is_none")] + pub artistic: Option, +} + +/// An applied artistic effect: its `a14` name (e.g. `artisticBlur`) and the original picture. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Artistic { + pub effect: String, + pub original: MediaId, +} + +/// Picture corrections and colour (Picture Format ▸ Corrections / Color / Transparency). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PictureAdjust { + /// -1..1 + pub brightness: f64, + /// -1..1 + pub contrast: f64, + /// 0..4 (1 = unchanged) + pub saturation: Option, + pub grayscale: bool, + /// Recolour duotone (dark, light). + pub duotone: Option<(ColorRef, ColorRef)>, + /// Make a colour transparent (Set Transparent Color). + pub clear_color: Option, + pub sharpen: f64, + /// `a:biLevel`: black and white, split at this luminance (0–1). + #[serde(skip_serializing_if = "Option::is_none")] + pub bilevel: Option, + /// `a:alphaInv`: opacity inverted. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub alpha_inv: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PatternFill { + pub preset: String, + pub fg: ColorRef, + pub bg: ColorRef, +} + +// Boxing the large variant would change every match in the apps' readers and writers. +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Fill { + None, + Solid { + color: ColorRef, + }, + Gradient(Gradient), + Picture(PictureFill), + Pattern(PatternFill), + /// Use the containing group's fill. + Group, + /// Use the slide background (`useBgFill`). + Background, +} + +impl Fill { + pub fn solid(c: ColorRef) -> Self { + Fill::Solid { color: c } + } + pub fn is_none(&self) -> bool { + matches!(self, Fill::None) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LineCap { + #[default] + Flat, + Round, + Square, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LineJoin { + #[default] + Round, + Bevel, + Miter, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LineAlign { + #[default] + Center, + Inside, +} + +impl LineAlign { + pub fn xml(self) -> &'static str { + match self { + LineAlign::Center => "ctr", + LineAlign::Inside => "in", + } + } + + pub fn from_xml(value: &str) -> Option { + match value { + "ctr" => Some(LineAlign::Center), + "in" => Some(LineAlign::Inside), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Compound { + #[default] + Single, + Double, + ThickThin, + ThinThick, + Triple, +} + +impl Compound { + pub fn xml(self) -> &'static str { + match self { + Compound::Single => "sng", + Compound::Double => "dbl", + Compound::ThickThin => "thickThin", + Compound::ThinThick => "thinThick", + Compound::Triple => "tri", + } + } + pub fn from_xml(s: &str) -> Self { + match s { + "dbl" => Compound::Double, + "thickThin" => Compound::ThickThin, + "thinThick" => Compound::ThinThick, + "tri" => Compound::Triple, + _ => Compound::Single, + } + } +} + +/// Dash style: a preset name (`solid`, `dot`, `dash`, `lgDash`, `dashDot`, `lgDashDot`, +/// `lgDashDotDot`, `sysDash`, `sysDot`, `sysDashDot`, `sysDashDotDot`) or custom (dash, space) pairs +/// in multiples of the line width. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Dash { + Preset { name: String }, + Custom { pattern: Vec<(f64, f64)> }, +} + +impl Dash { + pub const PRESETS: [&str; 11] = + ["solid", "sysDot", "sysDash", "dash", "dashDot", "lgDash", "lgDashDot", "lgDashDotDot", "dot", "sysDashDot", "sysDashDotDot"]; + /// Dash/space lengths in multiples of the line width. + pub fn pattern(&self) -> Vec { + match self { + Dash::Custom { pattern } => pattern.iter().flat_map(|(d, s)| [*d, *s]).collect(), + Dash::Preset { name } => match name.as_str() { + "dot" => vec![1.0, 3.0], + "dash" => vec![4.0, 3.0], + "lgDash" => vec![8.0, 3.0], + "dashDot" => vec![4.0, 3.0, 1.0, 3.0], + "lgDashDot" => vec![8.0, 3.0, 1.0, 3.0], + "lgDashDotDot" => vec![8.0, 3.0, 1.0, 3.0, 1.0, 3.0], + "sysDash" => vec![3.0, 1.0], + "sysDot" => vec![1.0, 1.0], + "sysDashDot" => vec![3.0, 1.0, 1.0, 1.0], + "sysDashDotDot" => vec![3.0, 1.0, 1.0, 1.0, 1.0, 1.0], + _ => vec![], + }, + } + } +} + +/// Arrowhead at a line end. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LineEnd { + /// `none`, `triangle`, `stealth`, `diamond`, `oval`, `arrow`. + pub kind: String, + /// `sm`, `med`, `lg`. + #[serde(default = "med")] + pub w: String, + #[serde(default = "med")] + pub len: String, +} + +fn med() -> String { + "med".into() +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Line { + pub fill: Option, + /// Width in points. + pub width: Option, + pub dash: Option, + pub cap: Option, + pub join: Option, + pub miter_limit: Option, + pub align: Option, + pub compound: Option, + pub head: Option, + pub tail: Option, +} + +impl Line { + pub fn solid(c: ColorRef, width: f64) -> Self { + Line { fill: Some(Fill::solid(c)), width: Some(width), ..Default::default() } + } + pub fn none() -> Self { + Line { fill: Some(Fill::None), ..Default::default() } + } + /// Fill unset fields from `parent`. + pub fn inherit(&mut self, parent: &Line) { + if self.fill.is_none() { + self.fill.clone_from(&parent.fill); + } + self.width = self.width.or(parent.width); + if self.dash.is_none() { + self.dash.clone_from(&parent.dash); + } + self.cap = self.cap.or(parent.cap); + self.join = self.join.or(parent.join); + self.miter_limit = self.miter_limit.or(parent.miter_limit); + self.align = self.align.or(parent.align); + self.compound = self.compound.or(parent.compound); + if self.head.is_none() { + self.head.clone_from(&parent.head); + } + if self.tail.is_none() { + self.tail.clone_from(&parent.tail); + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Shadow { + pub color: ColorRef, + /// Blur radius, points. + pub blur: f64, + /// Distance, points. + pub dist: f64, + /// Direction, degrees (0 = right, 90 = down). + pub dir: f64, + #[serde(default)] + pub inner: bool, + /// Scale (1 = 100%) for perspective shadows. + #[serde(default = "one")] + pub sx: f64, + #[serde(default = "one")] + pub sy: f64, + #[serde(default)] + pub kx: f64, + #[serde(default)] + pub ky: f64, + #[serde(default)] + pub align: String, + #[serde(default = "crate::yes")] + pub rotate_with_shape: bool, +} + +fn one() -> f64 { + 1.0 +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Glow { + pub color: ColorRef, + pub radius: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Reflection { + pub blur: f64, + pub start_alpha: f64, + pub end_alpha: f64, + pub end_pos: f64, + pub dist: f64, + /// DrawingML reflection transform values retain their native integer units. + pub st_pos: Option, + pub dir: Option, + pub fade_dir: Option, + pub sx: Option, + pub sy: Option, + pub kx: Option, + pub ky: Option, + pub align: Option, + pub rotate_with_shape: Option, +} + +impl Default for Reflection { + fn default() -> Self { + Self { + blur: 0.0, + start_alpha: 0.0, + end_alpha: 0.0, + end_pos: 0.0, + dist: 0.0, + st_pos: None, + dir: Some(5_400_000), + fade_dir: None, + sx: None, + sy: Some(-100_000), + kx: None, + ky: None, + align: Some("bl".into()), + rotate_with_shape: Some(false), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Effects { + pub outer_shadow: Option, + pub inner_shadow: Option, + pub glow: Option, + /// Soft edges radius in points. + pub soft_edge: Option, + pub reflection: Option, + /// Bevel preset name for 3-D format (rendered as an approximation). + pub bevel: Option, + /// Unmodelled 3-D XML kept for round-trip (`scene3d`, `sp3d`). + pub raw3d: Option, + /// `a:fillOverlay`: a fill blended over the shape's fill (`blend`: `over`, `mult`, `screen`, + /// `darken`, `lighten`). Only solid and gradient overlays are kept. + pub fill_overlay: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FillOverlay { + pub blend: String, + pub fill: Fill, +} + +impl Effects { + pub fn is_empty(&self) -> bool { + self.outer_shadow.is_none() + && self.inner_shadow.is_none() + && self.glow.is_none() + && self.soft_edge.is_none() + && self.reflection.is_none() + && self.bevel.is_none() + && self.fill_overlay.is_none() + } +} + +/// A shape's theme style references (`p:style`): which fill/line/effect of the theme's format scheme +/// to use, with which colour, and the theme font and colour for its text. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShapeStyle { + pub line_ref: (u32, ColorRef), + pub fill_ref: (u32, ColorRef), + pub effect_ref: (u32, ColorRef), + /// `major`, `minor` or `none`, with the text colour. + pub font_ref: (String, Option), +} + +impl ShapeStyle { + /// The default for newly drawn shapes: accent 1 fill, darker accent 1 outline, light text. + pub fn accent(slot: SchemeSlot) -> Self { + ShapeStyle { + line_ref: (2, ColorRef::scheme(slot).with(ColorTransform::Shade(50000))), + fill_ref: (1, ColorRef::scheme(slot)), + effect_ref: (0, ColorRef::scheme(slot)), + font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Lt1))), + } + } + /// The default for lines/connectors. + pub fn line(slot: SchemeSlot) -> Self { + ShapeStyle { + line_ref: (1, ColorRef::scheme(slot)), + fill_ref: (0, ColorRef::scheme(slot)), + effect_ref: (0, ColorRef::scheme(slot)), + font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Tx1))), + } + } +} diff --git a/crates/drawingml/src/table.rs b/crates/drawingml/src/table.rs new file mode 100644 index 000000000..7772565e4 --- /dev/null +++ b/crates/drawingml/src/table.rs @@ -0,0 +1,649 @@ +//! Tables (graphic frames with `a:tbl`). + +use serde::{Deserialize, Serialize}; + +use crate::style::{ColorRef, Fill, Line}; +use crate::text::{Anchor, TextBody}; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Cell { + pub text: TextBody, + pub fill: Option, + /// Left, right, top, bottom borders; `None` = from the table style. + pub borders: [Option; 4], + pub diag_down: Option, + pub diag_up: Option, + /// Margins (l, r, t, b) in points; `None` = defaults (7.2, 7.2, 3.6, 3.6). + pub margins: Option<[f64; 4]>, + pub anchor: Option, + pub grid_span: u32, + pub row_span: u32, + /// Covered by a merge to the left / above. + pub h_merge: bool, + pub v_merge: bool, + pub vertical: Option, + pub anchor_ctr: Option, + pub horz_overflow: Option, +} + +impl Cell { + pub fn new(text: &str) -> Self { + Cell { text: TextBody::from_text(text), grid_span: 1, row_span: 1, ..Default::default() } + } + pub fn is_covered(&self) -> bool { + self.h_merge || self.v_merge + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Row { + pub height: f64, + pub cells: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Table { + /// Column widths in points. + pub cols: Vec, + pub rows: Vec, + /// Table style id (our built-in style names or a PPTX style GUID). + pub style: String, + pub first_row: bool, + pub first_col: bool, + pub last_row: bool, + pub last_col: bool, + pub band_row: bool, + pub band_col: bool, + /// Whether columns are ordered right-to-left (`a:tblPr/@rtl`). + #[serde(skip_serializing_if = "Option::is_none")] + pub rtl: Option, + /// `a:tblPr` fill: a fill behind the whole table (over the style's own background). + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, +} + +impl Default for Table { + fn default() -> Self { + Table { + cols: vec![], + rows: vec![], + style: "medium2-accent1".into(), + first_row: true, + first_col: false, + last_row: false, + last_col: false, + band_row: true, + band_col: false, + rtl: None, + background: None, + } + } +} + +impl Table { + pub fn new(rows: usize, cols: usize, width: f64, row_height: f64) -> Self { + let rows = rows.clamp(1, 1000); + let cols = cols.clamp(1, 1000); + let cw = if cols > 0 { width / cols as f64 } else { width }; + Table { + cols: vec![cw; cols], + rows: (0..rows).map(|_| Row { height: row_height, cells: (0..cols).map(|_| Cell::new("")).collect() }).collect(), + ..Default::default() + } + } + pub fn n_rows(&self) -> usize { + self.rows.len() + } + pub fn n_cols(&self) -> usize { + self.cols.len() + } + pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> { + self.rows.get(r).and_then(|row| row.cells.get(c)) + } + pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> { + self.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) + } + pub fn width(&self) -> f64 { + self.cols.iter().sum() + } + pub fn height(&self) -> f64 { + self.rows.iter().map(|r| r.height).sum() + } + /// Insert an empty row before `at`. A merge the new row falls inside grows to cover it. + pub fn insert_row(&mut self, at: usize) { + let at = at.min(self.rows.len()); + let merges = self.merges(); + let h = self.rows.get(at.saturating_sub(1)).map(|r| r.height).unwrap_or(30.0); + self.rows.insert(at, Row { height: h, cells: (0..self.cols.len()).map(|_| Cell::new("")).collect() }); + self.set_merges(merges.into_iter().map(|m| m.insert_row(at))); + } + /// Insert an empty column before `at`. A merge the new column falls inside grows to cover it. + pub fn insert_col(&mut self, at: usize) { + let at = at.min(self.cols.len()); + let merges = self.merges(); + let w = self.cols.get(at.saturating_sub(1)).copied().unwrap_or(80.0); + self.cols.insert(at, w); + for r in &mut self.rows { + let i = at.min(r.cells.len()); + r.cells.insert(i, Cell::new("")); + } + self.set_merges(merges.into_iter().map(|m| m.insert_col(at))); + } + /// Delete row `at`. Merges through it shrink; a merge anchored on it moves its anchor (and + /// text) to the next row it covers. + pub fn delete_row(&mut self, at: usize) -> bool { + if self.rows.len() <= 1 || at >= self.rows.len() { + return false; + } + let merges = self.merges(); + for m in &merges { + if m.r == at && m.rs > 1 { + self.move_anchor((m.r, m.c), (m.r + 1, m.c)); + } + } + self.rows.remove(at); + self.set_merges(merges.into_iter().filter_map(|m| m.delete_row(at))); + true + } + /// Delete column `at`. Merges through it shrink; a merge anchored on it moves its anchor (and + /// text) to the next column it covers. + pub fn delete_col(&mut self, at: usize) -> bool { + if self.cols.len() <= 1 || at >= self.cols.len() { + return false; + } + let merges = self.merges(); + for m in &merges { + if m.c == at && m.gs > 1 { + self.move_anchor((m.r, m.c), (m.r, m.c + 1)); + } + } + self.cols.remove(at); + for r in &mut self.rows { + if at < r.cells.len() { + r.cells.remove(at); + } + } + self.set_merges(merges.into_iter().filter_map(|m| m.delete_col(at))); + true + } + /// The merged areas: every uncovered cell spanning more than one cell, clipped to the grid. + fn merges(&self) -> Vec { + let (nr, nc) = (self.rows.len(), self.cols.len()); + let mut out = vec![]; + for (r, row) in self.rows.iter().enumerate() { + for (c, cell) in row.cells.iter().enumerate().take(nc) { + if cell.is_covered() { + continue; + } + let rs = (cell.row_span.max(1) as usize).min(nr - r); + let gs = (cell.grid_span.max(1) as usize).min(nc - c); + if rs > 1 || gs > 1 { + out.push(Merge { r, c, rs, gs }); + } + } + } + out + } + /// Rebuild every cell's spans and merge flags from `merges`. + fn set_merges(&mut self, merges: impl IntoIterator) { + for row in &mut self.rows { + for cell in &mut row.cells { + cell.grid_span = 1; + cell.row_span = 1; + cell.h_merge = false; + cell.v_merge = false; + } + } + for m in merges { + if m.rs <= 1 && m.gs <= 1 { + continue; + } + for r in m.r..m.r.saturating_add(m.rs) { + for c in m.c..m.c.saturating_add(m.gs) { + if let Some(cell) = self.cell_mut(r, c) { + cell.h_merge = c > m.c; + cell.v_merge = r > m.r; + } + } + } + if let Some(cell) = self.cell_mut(m.r, m.c) { + cell.grid_span = u32::try_from(m.gs).unwrap_or(u32::MAX); + cell.row_span = u32::try_from(m.rs).unwrap_or(u32::MAX); + } + } + } + /// Swap the anchor cell's contents into the cell that takes over as anchor. + fn move_anchor(&mut self, from: (usize, usize), to: (usize, usize)) { + let Some(a) = self.cell(from.0, from.1).cloned() else { return }; + let Some(b) = self.cell(to.0, to.1).cloned() else { return }; + if let Some(x) = self.cell_mut(to.0, to.1) { + *x = a; + } + if let Some(x) = self.cell_mut(from.0, from.1) { + *x = b; + } + } + /// Merge the rectangle (r0,c0)–(r1,c1) inclusive into its top-left cell. + pub fn merge(&mut self, r0: usize, c0: usize, r1: usize, c1: usize) -> bool { + let (r0, r1) = (r0.min(r1), r0.max(r1)); + let (c0, c1) = (c0.min(c1), c0.max(c1)); + if r1 >= self.rows.len() || c1 >= self.cols.len() || (r0 == r1 && c0 == c1) { + return false; + } + // Grow the rectangle until no existing merge sticks out of it. + let (mut r0, mut c0, mut r1, mut c1) = (r0, c0, r1, c1); + loop { + let before = (r0, c0, r1, c1); + for (r, row) in self.rows.iter().enumerate() { + for (c, cell) in row.cells.iter().enumerate().take(self.cols.len()) { + if cell.is_covered() { + continue; + } + let ar1 = r.saturating_add((cell.row_span.max(1) as usize).saturating_sub(1)).min(self.rows.len() - 1); + let ac1 = c.saturating_add((cell.grid_span.max(1) as usize).saturating_sub(1)).min(self.cols.len() - 1); + if r <= r1 && ar1 >= r0 && c <= c1 && ac1 >= c0 { + r0 = r0.min(r); + c0 = c0.min(c); + r1 = r1.max(ar1); + c1 = c1.max(ac1); + } + } + } + if (r0, c0, r1, c1) == before { + break; + } + } + let mut text = vec![]; + for r in r0..=r1 { + for c in c0..=c1 { + if let Some(cell) = self.cell_mut(r, c) { + if !cell.text.is_empty() { + text.append(&mut cell.text.paragraphs); + } + cell.h_merge = c > c0; + cell.v_merge = r > r0; + cell.grid_span = 1; + cell.row_span = 1; + } + } + } + if let Some(cell) = self.cell_mut(r0, c0) { + cell.grid_span = (c1 - c0 + 1) as u32; + cell.row_span = (r1 - r0 + 1) as u32; + cell.h_merge = false; + cell.v_merge = false; + if !text.is_empty() { + cell.text.paragraphs = text; + } + } + true + } + /// Undo a merge at (r, c). + pub fn split(&mut self, r: usize, c: usize) -> bool { + let Some(cell) = self.cell(r, c) else { return false }; + let (gs, rs) = (cell.grid_span.max(1) as usize, cell.row_span.max(1) as usize); + if gs == 1 && rs == 1 { + return false; + } + // Spans come from the file and may reach past the table; only walk the cells that exist. + for rr in r..r.saturating_add(rs).min(self.rows.len()) { + for cc in c..c.saturating_add(gs).min(self.cols.len()) { + if let Some(x) = self.cell_mut(rr, cc) { + x.h_merge = false; + x.v_merge = false; + x.grid_span = 1; + x.row_span = 1; + } + } + } + true + } + /// Make every row have exactly `cols.len()` cells (repair after loading). + pub fn normalize(&mut self) { + let n = self.cols.len(); + for r in &mut self.rows { + r.cells.resize_with(n, || Cell::new("")); + for c in &mut r.cells { + c.grid_span = c.grid_span.max(1); + c.row_span = c.row_span.max(1); + } + } + } +} + +/// A merged area: anchor (r, c) spanning `rs` rows and `gs` columns. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Merge { + r: usize, + c: usize, + rs: usize, + gs: usize, +} + +impl Merge { + fn insert_row(self, at: usize) -> Self { + if at <= self.r { + Merge { r: self.r + 1, ..self } + } else if at < self.r + self.rs { + Merge { rs: self.rs + 1, ..self } + } else { + self + } + } + fn insert_col(self, at: usize) -> Self { + if at <= self.c { + Merge { c: self.c + 1, ..self } + } else if at < self.c + self.gs { + Merge { gs: self.gs + 1, ..self } + } else { + self + } + } + fn delete_row(self, at: usize) -> Option { + let m = if at < self.r { + Merge { r: self.r - 1, ..self } + } else if at < self.r + self.rs { + Merge { rs: self.rs - 1, ..self } + } else { + self + }; + // A merge that lost its only row or column is gone, not a 0-span merge over its neighbours. + (m.rs >= 1 && m.gs >= 1 && (m.rs > 1 || m.gs > 1)).then_some(m) + } + fn delete_col(self, at: usize) -> Option { + let m = if at < self.c { + Merge { c: self.c - 1, ..self } + } else if at < self.c + self.gs { + Merge { gs: self.gs - 1, ..self } + } else { + self + }; + // A merge that lost its only row or column is gone, not a 0-span merge over its neighbours. + (m.rs >= 1 && m.gs >= 1 && (m.rs > 1 || m.gs > 1)).then_some(m) + } +} + +/// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in +/// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and +/// last rows and columns, corner cells) is filled, bordered and lettered. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TableStyleDef { + /// The style's GUID (`a:tableStyleId` on tables). + pub id: String, + pub name: String, + pub parts: Vec<(TablePart, TablePartStyle)>, + /// `a:tblBg`: a fill behind the whole table (only `fill` and `fill_ref` are used). + pub background: Option, + /// The definition as read, written back unchanged. + #[serde(skip_serializing_if = "String::is_empty")] + pub raw: String, +} + +impl TableStyleDef { + pub fn part(&self, p: TablePart) -> Option<&TablePartStyle> { + self.parts.iter().find(|(k, _)| *k == p).map(|(_, s)| s) + } +} + +/// The parts of a table style, in the order they apply (later ones win). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TablePart { + WholeTbl, + Band1H, + Band2H, + Band1V, + Band2V, + LastCol, + FirstCol, + LastRow, + FirstRow, + SeCell, + SwCell, + NeCell, + NwCell, +} + +impl TablePart { + pub const ALL: [TablePart; 13] = [ + TablePart::WholeTbl, + TablePart::Band1H, + TablePart::Band2H, + TablePart::Band1V, + TablePart::Band2V, + TablePart::LastCol, + TablePart::FirstCol, + TablePart::LastRow, + TablePart::FirstRow, + TablePart::SeCell, + TablePart::SwCell, + TablePart::NeCell, + TablePart::NwCell, + ]; + + pub fn xml(self) -> &'static str { + match self { + TablePart::WholeTbl => "wholeTbl", + TablePart::Band1H => "band1H", + TablePart::Band2H => "band2H", + TablePart::Band1V => "band1V", + TablePart::Band2V => "band2V", + TablePart::LastCol => "lastCol", + TablePart::FirstCol => "firstCol", + TablePart::LastRow => "lastRow", + TablePart::FirstRow => "firstRow", + TablePart::SeCell => "seCell", + TablePart::SwCell => "swCell", + TablePart::NeCell => "neCell", + TablePart::NwCell => "nwCell", + } + } + + pub fn from_xml(s: &str) -> Option { + TablePart::ALL.into_iter().find(|p| p.xml() == s) + } +} + +/// One part of a table style. A theme reference (`a:fillRef`, `a:lnRef`) is kept as the theme's +/// style index with its colour. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TablePartStyle { + pub fill: Option, + pub fill_ref: Option<(u32, ColorRef)>, + /// Left, right, top, bottom, inside horizontal, inside vertical. + pub borders: [Option; 6], + pub text_color: Option, + pub bold: Option, + pub italic: Option, +} + +/// A border of a table style part: a line, or a theme line style reference. +// Boxing the large variant would change every match in the apps' readers and writers. +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum TableBorder { + Line { line: Line }, + Ref { idx: u32, color: ColorRef }, +} + +/// Built-in table styles: (id, label). Styles are generated from the theme accents. +pub fn table_styles() -> Vec<(String, String)> { + let mut v = vec![("none".to_string(), "No Style, No Grid".to_string()), ("grid".to_string(), "No Style, Table Grid".to_string())]; + for (kind, label) in [ + ("light1", "Light Style 1"), + ("light2", "Light Style 2"), + ("medium2", "Medium Style 2"), + ("medium4", "Medium Style 4"), + ("dark1", "Dark Style 1"), + ] { + v.push((format!("{kind}-tx1"), label.to_string())); + for a in 1..=6 { + v.push((format!("{kind}-accent{a}"), format!("{label} - Accent {a}"))); + } + } + v +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every cell is owned by exactly one anchor (itself or a span over it), and no span leaves the grid. + fn assert_topology(t: &Table) { + let (nr, nc) = (t.n_rows(), t.n_cols()); + let mut owner = vec![vec![0u32; nc]; nr]; + for (r, row) in t.rows.iter().enumerate() { + assert_eq!(row.cells.len(), nc); + for (c, cell) in row.cells.iter().enumerate() { + if cell.is_covered() { + assert_eq!((cell.grid_span, cell.row_span), (1, 1), "covered ({r},{c}) has spans"); + continue; + } + let (rs, gs) = (cell.row_span as usize, cell.grid_span as usize); + assert!(r + rs <= nr && c + gs <= nc, "span at ({r},{c}) leaves the grid"); + for o in owner.iter_mut().skip(r).take(rs) { + for x in o.iter_mut().skip(c).take(gs) { + *x += 1; + } + } + } + } + for (r, o) in owner.iter().enumerate() { + for (c, n) in o.iter().enumerate() { + assert_eq!(*n, 1, "cell ({r},{c}) owned {n} times"); + } + } + } + + fn merged() -> Table { + // 4x4 with a 2x2 merge at (1,1) holding "M". + let mut t = Table::new(4, 4, 400.0, 30.0); + if let Some(c) = t.cell_mut(1, 1) { + c.text = TextBody::from_text("M"); + } + assert!(t.merge(1, 1, 2, 2)); + assert_topology(&t); + t + } + + fn anchor_text(t: &Table, r: usize, c: usize) -> String { + t.cell(r, c).unwrap().text.text() + } + + #[test] + fn merging_over_an_existing_merge_takes_it_in() { + let mut t = Table::new(3, 3, 300.0, 30.0); + if let Some(c) = t.cell_mut(1, 2) { + c.text = TextBody::from_text("X"); + } + assert!(t.merge(1, 1, 1, 2)); + assert!(t.merge(0, 0, 1, 1)); + assert_topology(&t); + let a = t.cell(0, 0).unwrap(); + assert_eq!((a.row_span, a.grid_span), (2, 3)); + assert_eq!(anchor_text(&t, 0, 0), "X"); + } + + #[test] + fn row_and_column_edits_keep_merges_consistent() { + for at in 0..=4 { + let mut t = merged(); + t.insert_row(at); + assert_topology(&t); + let mut t = merged(); + t.insert_col(at); + assert_topology(&t); + } + for at in 0..4 { + let mut t = merged(); + assert!(t.delete_row(at)); + assert_topology(&t); + let mut t = merged(); + assert!(t.delete_col(at)); + assert_topology(&t); + } + } + + #[test] + fn inserting_inside_a_merge_grows_it() { + let mut t = merged(); + t.insert_row(2); + let a = t.cell(1, 1).unwrap(); + assert_eq!((a.row_span, a.grid_span), (3, 2)); + assert!(t.cell(2, 2).unwrap().h_merge && t.cell(2, 2).unwrap().v_merge); + t.insert_col(0); + assert_eq!(t.cell(1, 2).unwrap().grid_span, 2); + assert_eq!(anchor_text(&t, 1, 2), "M"); + assert_topology(&t); + } + + #[test] + fn deleting_the_anchor_row_or_column_keeps_the_text() { + let mut t = merged(); + assert!(t.delete_row(1)); + let a = t.cell(1, 1).unwrap(); + assert!(!a.is_covered()); + assert_eq!((a.row_span, a.grid_span), (1, 2)); + assert_eq!(anchor_text(&t, 1, 1), "M"); + assert!(t.delete_col(1)); + let a = t.cell(1, 1).unwrap(); + assert!(!a.is_covered()); + assert_eq!((a.row_span, a.grid_span), (1, 1)); + assert_eq!(anchor_text(&t, 1, 1), "M"); + assert_topology(&t); + } + + #[test] + fn deleting_the_only_row_or_column_of_a_merge_removes_it() { + // A 1x3 merge across a row: deleting that row leaves no merge behind. + let mut t = Table::new(3, 3, 300.0, 30.0); + assert!(t.merge(1, 0, 1, 2)); + assert!(t.delete_row(1)); + assert!((0..t.n_rows()).all(|r| (0..t.n_cols()).all(|c| t.cell(r, c).is_some_and(|x| !x.is_covered() && x.grid_span <= 1)))); + assert_topology(&t); + // A 3x1 merge down a column: deleting that column leaves no merge behind. + let mut t = Table::new(3, 3, 300.0, 30.0); + assert!(t.merge(0, 1, 2, 1)); + assert!(t.delete_col(1)); + assert!((0..t.n_rows()).all(|r| (0..t.n_cols()).all(|c| t.cell(r, c).is_some_and(|x| !x.is_covered() && x.row_span <= 1)))); + assert_topology(&t); + } + + #[test] + fn insert_delete_merge_split() { + let mut t = Table::new(3, 3, 300.0, 30.0); + assert_eq!((t.n_rows(), t.n_cols()), (3, 3)); + t.insert_row(1); + t.insert_col(3); + assert_eq!((t.n_rows(), t.n_cols()), (4, 4)); + assert!(t.delete_row(0) && t.delete_col(0)); + assert_eq!((t.n_rows(), t.n_cols()), (3, 3)); + assert!(t.merge(0, 0, 1, 1)); + assert_eq!(t.cell(0, 0).map(|c| (c.grid_span, c.row_span)), Some((2, 2))); + assert!(t.cell(1, 1).is_some_and(|c| c.is_covered())); + assert!(t.split(0, 0)); + assert!(!t.cell(1, 1).is_some_and(|c| c.is_covered())); + assert!(!t.merge(5, 5, 6, 6)); + assert!(!t.delete_row(99)); + let mut one = Table::new(1, 1, 10.0, 10.0); + assert!(!one.delete_row(0) && !one.delete_col(0)); + assert!(table_styles().len() > 30); + } + + #[test] + fn split_ignores_spans_past_the_table() { + let mut t = Table::new(2, 2, 100.0, 10.0); + if let Some(c) = t.cell_mut(0, 0) { + c.grid_span = u32::MAX; + c.row_span = u32::MAX; + } + assert!(t.split(0, 0)); + assert_eq!(t.cell(0, 0).map(|c| (c.grid_span, c.row_span)), Some((1, 1))); + } +} diff --git a/crates/drawingml/src/text.rs b/crates/drawingml/src/text.rs new file mode 100644 index 000000000..f60f23236 --- /dev/null +++ b/crates/drawingml/src/text.rs @@ -0,0 +1,819 @@ +//! Text bodies: body properties, paragraphs, runs, list styles. + +use serde::{Deserialize, Serialize}; + +use crate::style::{ColorRef, Fill, Line}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Anchor { + Top, + Middle, + Bottom, + Justified, + Distributed, +} + +impl Anchor { + pub fn xml(self) -> &'static str { + match self { + Anchor::Top => "t", + Anchor::Middle => "ctr", + Anchor::Bottom => "b", + Anchor::Justified => "just", + Anchor::Distributed => "dist", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "t" => Anchor::Top, + "ctr" => Anchor::Middle, + "b" => Anchor::Bottom, + "just" => Anchor::Justified, + "dist" => Anchor::Distributed, + _ => return None, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum AutoFit { + /// Do not autofit. + None, + /// Shrink text on overflow: font scale and line-spacing reduction (0–1). `font_scale` is + /// `None` for a file's `` with no `fontScale`: the schema default 100 %, + /// which PowerPoint draws as it is and only fits again when the text changes (#724). + Shrink { + #[serde(skip_serializing_if = "Option::is_none")] + font_scale: Option, + line_reduction: f64, + }, + /// Resize shape to fit text. + Shape, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextDir { + Horizontal, + Vertical, + Vertical270, + Stacked, + EaVertical, + /// `mongolianVert`: turned like `vert`, lines running from left to right. + MongolianVertical, + /// `wordArtVertRtl`: stacked, lines running from right to left. + StackedRtl, +} + +impl TextDir { + pub fn xml(self) -> &'static str { + match self { + TextDir::Horizontal => "horz", + TextDir::Vertical => "vert", + TextDir::Vertical270 => "vert270", + TextDir::Stacked => "wordArtVert", + TextDir::EaVertical => "eaVert", + TextDir::MongolianVertical => "mongolianVert", + TextDir::StackedRtl => "wordArtVertRtl", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "horz" => TextDir::Horizontal, + "vert" => TextDir::Vertical, + "mongolianVert" => TextDir::MongolianVertical, + "vert270" => TextDir::Vertical270, + "wordArtVert" => TextDir::Stacked, + "wordArtVertRtl" => TextDir::StackedRtl, + "eaVert" => TextDir::EaVertical, + _ => return None, + }) + } +} + +/// `bodyPr`: insets in points, anchoring, wrapping, autofit, columns, direction, warp. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BodyProps { + pub inset_l: Option, + pub inset_t: Option, + pub inset_r: Option, + pub inset_b: Option, + pub anchor: Option, + pub anchor_ctr: Option, + pub wrap: Option, + pub autofit: Option, + pub vert: Option, + /// Extra text rotation in degrees. + pub rot: Option, + pub columns: Option, + pub col_spacing: Option, + /// WordArt transform (`prstTxWarp`), e.g. `textArchUp`. + pub warp: Option, + /// The warp's adjust values (`prstTxWarp/avLst`: `adj` or `adj1`, `adj2`…, raw guide values); + /// empty = the preset's defaults. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub warp_adj: Vec, + pub rtl_col: Option, + /// Keep text upright when the shape rotates. + pub upright: Option, + /// Space before the first paragraph and after the last count too (`spcFirstLastPara`). + pub space_first_last: Option, + pub compat_ln_spc: Option, + pub vert_overflow: Option, + pub horz_overflow: Option, + /// Unmodelled 3-D XML on the text body (`a:scene3d`, `a:sp3d`, `a:flatTx`). + #[serde(skip_serializing_if = "Option::is_none")] + pub raw3d: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextOverflow { + Overflow, + Clip, + Ellipsis, +} + +impl TextOverflow { + pub fn xml(self) -> &'static str { + match self { + Self::Overflow => "overflow", + Self::Clip => "clip", + Self::Ellipsis => "ellipsis", + } + } + + pub fn from_xml(value: &str) -> Option { + Some(match value { + "overflow" => Self::Overflow, + "clip" => Self::Clip, + "ellipsis" => Self::Ellipsis, + _ => return None, + }) + } + + /// A `horzOverflow` value: `ST_TextHorzOverflowType` has only `overflow` and `clip`. + pub fn from_horz_xml(value: &str) -> Option { + Self::from_xml(value).filter(|v| *v != Self::Ellipsis) + } + + /// The `horzOverflow` attribute to write, leaving out `ellipsis`, which is vertical only. + pub fn horz_xml(v: Option) -> Option<&'static str> { + v.filter(|v| *v != Self::Ellipsis).map(Self::xml) + } +} + +impl BodyProps { + pub fn inherit(&mut self, p: &BodyProps) { + self.inset_l = self.inset_l.or(p.inset_l); + self.inset_t = self.inset_t.or(p.inset_t); + self.inset_r = self.inset_r.or(p.inset_r); + self.inset_b = self.inset_b.or(p.inset_b); + self.anchor = self.anchor.or(p.anchor); + self.anchor_ctr = self.anchor_ctr.or(p.anchor_ctr); + self.wrap = self.wrap.or(p.wrap); + self.autofit = self.autofit.or(p.autofit); + self.vert = self.vert.or(p.vert); + self.rot = self.rot.or(p.rot); + self.columns = self.columns.or(p.columns); + self.col_spacing = self.col_spacing.or(p.col_spacing); + if self.warp.is_none() { + self.warp.clone_from(&p.warp); + self.warp_adj.clone_from(&p.warp_adj); + } + self.rtl_col = self.rtl_col.or(p.rtl_col); + self.upright = self.upright.or(p.upright); + self.space_first_last = self.space_first_last.or(p.space_first_last); + self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc); + self.vert_overflow = self.vert_overflow.or(p.vert_overflow); + self.horz_overflow = self.horz_overflow.or(p.horz_overflow); + if self.raw3d.is_none() { + self.raw3d.clone_from(&p.raw3d); + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Align { + Left, + Center, + Right, + Justify, + /// Justified with kashida (tatweel) elongation for Arabic (`justLow`). + JustLow, + Distributed, + ThaiDistributed, +} + +impl Align { + pub fn xml(self) -> &'static str { + match self { + Align::Left => "l", + Align::Center => "ctr", + Align::Right => "r", + Align::Justify => "just", + Align::JustLow => "justLow", + Align::Distributed => "dist", + Align::ThaiDistributed => "thaiDist", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "l" => Align::Left, + "ctr" => Align::Center, + "r" => Align::Right, + "just" => Align::Justify, + "thaiDist" => Align::ThaiDistributed, + "justLow" => Align::JustLow, + "dist" => Align::Distributed, + _ => return None, + }) + } +} + +/// Spacing as a percentage of the line (1.0 = single) or in points. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "v")] +pub enum Spacing { + Pct(f64), + Pts(f64), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Bullet { + None, + Char { + char: String, + }, + /// `arabicPeriod`, `arabicParenR`, `romanUcPeriod`, `romanLcPeriod`, `alphaUcPeriod`, + /// `alphaLcParenR`, `alphaLcPeriod`, `circleNumDbPlain`… + AutoNum { + scheme: String, + #[serde(default = "start_one")] + start_at: u32, + }, + Picture { + media: crate::MediaId, + }, +} + +fn start_one() -> u32 { + 1 +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TabStop { + pub pos: f64, + /// `l`, `ctr`, `r`, `dec`. + pub align: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ParaProps { + pub align: Option, + /// Left margin (points). + pub margin_left: Option, + pub margin_right: Option, + /// First-line indent relative to the margin (negative = hanging). + pub indent: Option, + pub line_spacing: Option, + pub space_before: Option, + pub space_after: Option, + pub bullet: Option, + pub bullet_font: Option, + pub bullet_font_follow_text: Option, + /// `None` = follow text colour. + pub bullet_color: Option, + pub bullet_color_follow_text: Option, + /// Bullet size as a fraction of the text size. + pub bullet_size: Option, + /// Absolute bullet font size in points, independent of the paragraph's run size. + pub bullet_size_points: Option, + pub bullet_size_follow_text: Option, + pub tabs: Option>, + pub default_tab: Option, + pub rtl: Option, + pub font_align: Option, + pub east_asian_line_break: Option, + /// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default. + pub latin_line_break: Option, + /// `hangingPunct`: closing East Asian punctuation may hang past the end of a line. + pub hanging_punct: Option, +} + +impl ParaProps { + pub fn inherit(&mut self, p: &ParaProps) { + self.align = self.align.or(p.align); + self.margin_left = self.margin_left.or(p.margin_left); + self.margin_right = self.margin_right.or(p.margin_right); + self.indent = self.indent.or(p.indent); + self.line_spacing = self.line_spacing.or(p.line_spacing); + self.space_before = self.space_before.or(p.space_before); + self.space_after = self.space_after.or(p.space_after); + if self.bullet.is_none() { + self.bullet.clone_from(&p.bullet); + } + if self.bullet_font_follow_text == Some(false) || self.bullet_font.is_some() { + self.bullet_font_follow_text = None; + } + if self.bullet_font.is_none() && self.bullet_font_follow_text.is_none() { + if p.bullet_font_follow_text == Some(true) { + self.bullet_font_follow_text = Some(true); + } else { + self.bullet_font.clone_from(&p.bullet_font); + } + } + if self.bullet_color_follow_text == Some(false) || self.bullet_color.is_some() { + self.bullet_color_follow_text = None; + } + if self.bullet_color.is_none() && self.bullet_color_follow_text.is_none() { + if p.bullet_color_follow_text == Some(true) { + self.bullet_color_follow_text = Some(true); + } else { + self.bullet_color.clone_from(&p.bullet_color); + } + } + if self.bullet_size_follow_text == Some(false) || self.bullet_size.is_some() || self.bullet_size_points.is_some() { + self.bullet_size_follow_text = None; + } + if self.bullet_size.is_none() && self.bullet_size_points.is_none() && self.bullet_size_follow_text.is_none() { + if p.bullet_size_points.is_some() { + self.bullet_size_points = p.bullet_size_points; + } else if p.bullet_size_follow_text == Some(true) { + self.bullet_size_follow_text = Some(true); + } else { + self.bullet_size = p.bullet_size; + } + } + if self.tabs.is_none() { + self.tabs.clone_from(&p.tabs); + } + self.default_tab = self.default_tab.or(p.default_tab); + self.rtl = self.rtl.or(p.rtl); + if self.font_align.is_none() { + self.font_align.clone_from(&p.font_align); + } + self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break); + self.latin_line_break = self.latin_line_break.or(p.latin_line_break); + self.hanging_punct = self.hanging_punct.or(p.hanging_punct); + } +} + +#[cfg(test)] +mod bullet_size_tests { + use super::ParaProps; + + #[test] + fn bullet_size_inheritance_keeps_percentage_and_points_as_one_mode() { + let parent = ParaProps { bullet_size_points: Some(12.0), ..Default::default() }; + let mut child = ParaProps { bullet_size: Some(2.0), ..Default::default() }; + child.inherit(&parent); + assert_eq!(child.bullet_size, Some(2.0)); + assert_eq!(child.bullet_size_points, None); + + let parent = ParaProps { bullet_size: Some(1.5), ..Default::default() }; + let mut child = ParaProps { bullet_size_points: Some(14.0), ..Default::default() }; + child.inherit(&parent); + assert_eq!(child.bullet_size, None); + assert_eq!(child.bullet_size_points, Some(14.0)); + + let parent = ParaProps { bullet_size_points: Some(12.0), ..Default::default() }; + let mut child = ParaProps { bullet_size_follow_text: Some(true), ..Default::default() }; + child.inherit(&parent); + assert_eq!(child.bullet_size, None); + assert_eq!(child.bullet_size_points, None); + assert_eq!(child.bullet_size_follow_text, Some(true)); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Caps { + None, + Small, + All, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Strike { + None, + Single, + Double, +} + +/// A hyperlink or action on text or a shape. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Action { + Url { + url: String, + }, + Slide { + slide: crate::SlideId, + }, + NextSlide, + PreviousSlide, + FirstSlide, + LastSlide, + EndShow, + LastViewed, + /// Run a custom show. `returns` is PowerPoint's "Show and return". + CustomShow { + name: String, + #[serde(default = "custom_show_returns", skip_serializing_if = "is_true")] + returns: bool, + }, + Program { + path: String, + }, + PlayMedia, +} + +/// Can a link from a (possibly untrusted) deck be opened outside it, by the slide show or by +/// whoever opens an exported file? Only web pages and mail: `file:`, `javascript:`, relative paths +/// and other schemes (OS protocol handlers, network shares) are not. +pub fn safe_url(url: &str) -> bool { + let u = url.trim(); + let Some((scheme, rest)) = u.split_once(':') else { return false }; + match scheme.to_ascii_lowercase().as_str() { + "http" | "https" => rest.starts_with("//") && rest.len() > 2, + "mailto" => !rest.is_empty(), + _ => false, + } +} + +fn custom_show_returns() -> bool { + true +} + +fn is_true(v: &bool) -> bool { + *v +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Hyperlink { + pub action: Action, + #[serde(default)] + pub tooltip: String, + #[serde(default)] + pub highlight_click: bool, + /// Sound played when the action runs (`a:snd`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sound: Option, + /// `endSnd`: stop the sound that is already playing. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stop_sound: bool, + /// Zoom (Insert ▸ Zoom) with Return to Zoom: in the show, after this slide is shown and + /// advanced past, go back to the slide with the zoom. Saved to .pptx as a plain slide link. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub zoom_return: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct RunProps { + /// Latin font family, or a theme reference `+mj-lt` / `+mn-lt`. + pub font: Option, + pub font_ea: Option, + pub font_cs: Option, + pub font_sym: Option, + /// Size in points. + pub size: Option, + pub bold: Option, + pub italic: Option, + /// `none`, `sng`, `dbl`, `heavy`, `dotted`, `dash`, `wavy`… + pub underline: Option, + pub underline_color: Option, + pub strike: Option, + /// Baseline offset as a fraction of the size (0.3 = superscript 30%). + pub baseline: Option, + pub caps: Option, + /// Character spacing in points. + pub spacing: Option, + /// Kern at and above this size (points); 0 = off. + pub kern: Option, + pub fill: Option, + pub outline: Option, + pub highlight: Option, + pub link: Option, + /// Action on mouse over (Insert ▸ Action ▸ Mouse Over on selected text, `a:hlinkMouseOver`). + pub hover: Option, + pub lang: Option, + #[serde(deserialize_with = "bounded_language")] + pub alt_lang: Option, + pub shadow: Option, + /// `a:innerShdw` on the run. Outer shadow stays in `shadow`. + pub inner_shadow: Option, + pub glow: Option, + pub reflection: Option, + /// Soft-edge radius in points (`a:softEdge`). + pub soft_edge: Option, + /// Spelling/grammar errors are not flagged. + pub no_proof: Option, + pub normalize_height: Option, +} + +fn bounded_language<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|lang| lang.map(|value| value.chars().take(256).collect())) +} + +impl RunProps { + pub fn inherit(&mut self, p: &RunProps) { + macro_rules! take { + ($($f:ident),*) => { $( if self.$f.is_none() { self.$f.clone_from(&p.$f); } )* }; + } + take!( + font, + font_ea, + font_cs, + font_sym, + size, + bold, + italic, + underline, + underline_color, + strike, + baseline, + caps, + spacing, + kern, + fill, + outline, + highlight, + link, + hover, + lang, + alt_lang, + shadow, + inner_shadow, + glow, + reflection, + soft_edge, + no_proof, + normalize_height + ); + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum RunKind { + Text, + /// A line break inside the paragraph (Shift+Enter). + Break, + /// A field: `slidenum`, `datetime`, `datetime1`…`datetime13`, or other; `text` holds the last value. + Field { + field: String, + }, + /// An inline equation (OMML kept as XML; `text` is its linear form). + Math { + omml: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Run { + #[serde(default)] + pub text: String, + #[serde(default, skip_serializing_if = "is_default_props")] + pub props: RunProps, + #[serde(default = "text_kind", skip_serializing_if = "is_text_kind")] + pub kind: RunKind, +} + +fn is_default_props(p: &RunProps) -> bool { + *p == RunProps::default() +} +fn text_kind() -> RunKind { + RunKind::Text +} +fn is_text_kind(k: &RunKind) -> bool { + *k == RunKind::Text +} + +impl Run { + pub fn new(text: impl Into) -> Self { + Run { text: text.into(), props: RunProps::default(), kind: RunKind::Text } + } + pub fn with(text: impl Into, props: RunProps) -> Self { + Run { text: text.into(), props, kind: RunKind::Text } + } + /// The characters this run contributes to the paragraph (a break is one `\u{b}`). + pub fn content(&self) -> &str { + match self.kind { + RunKind::Break => "\u{b}", + _ => &self.text, + } + } + pub fn char_len(&self) -> usize { + self.content().chars().count() + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Paragraph { + /// Outline level 0–8. + pub level: u8, + #[serde(skip_serializing_if = "is_default_para")] + pub props: ParaProps, + pub runs: Vec, + /// `a:defRPr`: defaults for runs in this paragraph that omit a property. Sits above the + /// placeholder's list style, so a layout can still supply what this paragraph leaves unset. + #[serde(skip_serializing_if = "is_default_props")] + pub def_props: RunProps, + /// Properties of the paragraph end mark (used for an empty paragraph's caret). + #[serde(skip_serializing_if = "is_default_props")] + pub end_props: RunProps, +} + +fn is_default_para(p: &ParaProps) -> bool { + *p == ParaProps::default() +} + +impl Paragraph { + pub fn new(text: &str) -> Self { + Paragraph { runs: if text.is_empty() { vec![] } else { vec![Run::new(text)] }, ..Default::default() } + } + /// Leading tabs are the outline level (at most 8), the same rule as a typed indent. + /// Returns whether any tab was consumed. + pub fn promote_leading_tabs(&mut self) -> bool { + let Some(r) = self.runs.first_mut() else { return false }; + if r.kind != RunKind::Text { + return false; + } + let tabs = r.text.chars().take_while(|c| *c == '\t').count(); + if tabs == 0 { + return false; + } + r.text = r.text.trim_start_matches('\t').to_string(); + self.level = tabs.min(8) as u8; + true + } + pub fn text(&self) -> String { + self.runs.iter().map(Run::content).collect() + } + pub fn char_len(&self) -> usize { + self.runs.iter().map(Run::char_len).sum() + } + pub fn is_empty(&self) -> bool { + self.char_len() == 0 + } + /// Merge adjacent text runs with identical properties. + pub fn normalize(&mut self) { + let mut out: Vec = Vec::with_capacity(self.runs.len()); + for r in self.runs.drain(..) { + if r.kind == RunKind::Text && r.text.is_empty() { + continue; + } + if let Some(last) = out.last_mut() + && last.kind == RunKind::Text + && r.kind == RunKind::Text + && last.props == r.props + { + last.text.push_str(&r.text); + continue; + } + out.push(r); + } + self.runs = out; + } +} + +/// Per-level paragraph + run defaults (`lstStyle`, master text styles, `defaultTextStyle`). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LevelStyle { + pub para: ParaProps, + pub run: RunProps, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ListStyle { + /// Levels 1–9 (index 0–8); `None` = not specified. + pub levels: Vec>, +} + +impl ListStyle { + pub fn level(&self, l: u8) -> Option<&LevelStyle> { + self.levels.get(l as usize).and_then(Option::as_ref) + } + pub fn set(&mut self, l: u8, s: LevelStyle) { + let i = (l as usize).min(8); + if self.levels.len() <= i { + self.levels.resize(i + 1, None); + } + if let Some(slot) = self.levels.get_mut(i) { + *slot = Some(s); + } + } + pub fn is_empty(&self) -> bool { + self.levels.iter().all(Option::is_none) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextBody { + pub body: BodyProps, + #[serde(skip_serializing_if = "ListStyle::is_empty")] + pub list_style: ListStyle, + pub paragraphs: Vec, +} + +impl TextBody { + pub fn from_text(text: &str) -> Self { + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + TextBody { paragraphs: text.split('\n').map(Paragraph::new).collect(), ..Default::default() } + } + /// Plain text with `\n` between paragraphs. + pub fn text(&self) -> String { + self.paragraphs.iter().map(Paragraph::text).collect::>().join("\n") + } + pub fn is_empty(&self) -> bool { + self.paragraphs.iter().all(Paragraph::is_empty) + } + /// Total characters, counting one separator between paragraphs. + pub fn char_len(&self) -> usize { + let n: usize = self.paragraphs.iter().map(Paragraph::char_len).sum(); + n + self.paragraphs.len().saturating_sub(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::Rgba; + + #[test] + fn alternate_language_inherits_and_native_input_is_bounded() { + let mut child = RunProps::default(); + child.inherit(&RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }); + assert_eq!(child.lang.as_deref(), Some("en-US")); + assert_eq!(child.alt_lang.as_deref(), Some("fr-FR")); + + let oversized = "x".repeat(300); + let json = serde_json::json!({"altLang": oversized}); + let props: RunProps = serde_json::from_value(json).expect("run props"); + assert_eq!(props.alt_lang.as_deref().map(str::len), Some(256)); + assert_eq!(serde_json::from_value::(serde_json::json!({})).expect("old run props"), RunProps::default()); + } + + #[test] + fn explicit_bullet_follow_text_resets_block_inheritance() { + let parent = ParaProps { + bullet_font: Some("Inter".into()), + bullet_color: Some(ColorRef::rgb(Rgba::rgb(0, 0, 0))), + bullet_size: Some(2.0), + ..Default::default() + }; + let mut child = ParaProps { + bullet_font_follow_text: Some(true), + bullet_color_follow_text: Some(true), + bullet_size_follow_text: Some(true), + ..Default::default() + }; + + child.inherit(&parent); + + assert_eq!(child.bullet_font, None); + assert_eq!(child.bullet_color, None); + assert_eq!(child.bullet_size, None); + + let mut inherited_reset = ParaProps::default(); + inherited_reset.inherit(&ParaProps { + bullet_font_follow_text: Some(true), + bullet_color_follow_text: Some(true), + bullet_size_follow_text: Some(true), + ..Default::default() + }); + assert_eq!(inherited_reset.bullet_font_follow_text, Some(true)); + assert_eq!(inherited_reset.bullet_color_follow_text, Some(true)); + assert_eq!(inherited_reset.bullet_size_follow_text, Some(true)); + + let mut explicit_false = ParaProps { + bullet_font_follow_text: Some(false), + bullet_color_follow_text: Some(false), + bullet_size_follow_text: Some(false), + ..Default::default() + }; + explicit_false.inherit(&parent); + assert_eq!(explicit_false.bullet_font, parent.bullet_font); + assert_eq!(explicit_false.bullet_color, parent.bullet_color); + assert_eq!(explicit_false.bullet_size, parent.bullet_size); + } +} diff --git a/crates/drawingml/src/trendline.rs b/crates/drawingml/src/trendline.rs new file mode 100644 index 000000000..f6aa4b5c0 --- /dev/null +++ b/crates/drawingml/src/trendline.rs @@ -0,0 +1,766 @@ +//! Least-squares trendlines: linear, exponential, logarithmic, polynomial, power, and a +//! moving average. The fit is what the chart drawer plots and what the axis grows to include. + +use crate::chart::{ChartType, Series, Trendline, TrendlineType}; + +/// A fitted trendline. `y` is `None` where the model is undefined (a log of a non-positive x, +/// an exponential that overflowed). +#[derive(Clone, Debug)] +pub struct TrendFit { + kind: TrendlineType, + model: Model, + r2: f64, + x_min: f64, + x_max: f64, +} + +#[derive(Clone, Debug)] +enum Model { + /// `y = c[0] + c[1] x + c[2] x² + …` + Poly(Vec), + /// `y = c · e^(b x)` + Exp { c: f64, b: f64 }, + /// `y = b + c · ln(x)` + Log { b: f64, c: f64 }, + /// `y = c · x^b` + Power { c: f64, b: f64 }, + /// Window averages in x order. No equation. + Moving(Vec<(f64, f64)>), +} + +impl Trendline { + /// Fit `xs`/`ys` (paired). Non-finite pairs are dropped. `None` when the type needs more + /// points than it was given, or the normal equations have no solution. + pub fn fit(&self, xs: &[f64], ys: &[f64]) -> Option { + let mut pairs: Vec<(f64, f64)> = + xs.iter().zip(ys).map(|(x, y)| (*x, *y)).filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect(); + if pairs.len() < 2 { + return None; + } + pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let (x_min, x_max) = (pairs.first().map(|p| p.0)?, pairs.last().map(|p| p.0)?); + let intercept = self.intercept.filter(|v| v.is_finite()); + let (model, used_y, yhat) = match self.kind { + TrendlineType::Linear => { + let (xs, ys) = split(&pairs); + let (b, m) = linreg(&xs, &ys, intercept)?; + let yhat: Vec = xs.iter().map(|x| b + m * x).collect(); + (Model::Poly(vec![b, m]), ys, yhat) + } + TrendlineType::Poly => { + let order = self.order.unwrap_or(2).clamp(2, 6) as usize; + if pairs.len() < order + 1 { + return None; + } + let (xs, ys) = split(&pairs); + let coeff = polyfit(&xs, &ys, order, intercept)?; + let yhat: Vec = xs.iter().map(|x| poly_y(&coeff, *x)).collect(); + (Model::Poly(coeff), ys, yhat) + } + TrendlineType::Exp => fit_exp(&pairs, intercept)?, + TrendlineType::Log => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, _)| *x > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (ln_x, ys): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), *y)).unzip(); + let (b, c) = linreg(&ln_x, &ys, intercept)?; + let yhat: Vec = ln_x.iter().map(|u| b + c * u).collect(); + (Model::Log { b, c }, ys, yhat) + } + TrendlineType::Power => fit_power(&pairs, intercept)?, + TrendlineType::MovingAvg => { + let period = self.period.unwrap_or(2).clamp(2, 255) as usize; + fit_moving_avg(&pairs, period)? + } + }; + if yhat.iter().any(|v| !v.is_finite()) { + return None; + } + // A moving average starts at the first full window, not at the first data point. + let (x_min, x_max) = if let Model::Moving(pts) = &model { + match (pts.first(), pts.last()) { + (Some(a), Some(b)) => (a.0, b.0), + _ => (x_min, x_max), + } + } else { + (x_min, x_max) + }; + Some(TrendFit { kind: self.kind, model, r2: r_squared(&used_y, &yhat), x_min, x_max }) + } + + /// The equation and R² lines this trendline asks to show. Empty when neither is on. A moving + /// average has no equation, and PowerPoint does not show its R², so that label is left off + /// even when the file asks for it. + pub fn caption(&self, fit: &TrendFit) -> String { + let mut lines = Vec::new(); + if self.disp_eq + && let Some(eq) = fit.equation() + { + lines.push(eq); + } + if self.disp_r_sqr && self.kind != TrendlineType::MovingAvg { + lines.push(fit.r2_label()); + } + lines.join("\n") + } +} + +impl TrendFit { + pub fn y(&self, x: f64) -> Option { + if !x.is_finite() { + return None; + } + let y = match &self.model { + Model::Poly(c) => poly_y(c, x), + Model::Exp { c, b } => c * (b * x).exp(), + Model::Log { b, c } => { + if x <= 0.0 { + return None; + } + b + c * x.ln() + } + Model::Power { c, b } => { + if x <= 0.0 { + return None; + } + c * x.powf(*b) + } + Model::Moving(_) => return None, + }; + y.is_finite().then_some(y) + } + + /// Samples from `x0` to `x1`, forecast included. A moving average returns its window points + /// and ignores the forecast (Excel does not extend one). + pub fn samples(&self, x0: f64, x1: f64) -> Vec<(f64, f64)> { + let (x0, x1) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; + if !x0.is_finite() || !x1.is_finite() { + return Vec::new(); + } + if let Model::Moving(pts) = &self.model { + return pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect(); + } + if matches!(self.kind, TrendlineType::Linear) { + return [x0, x1].into_iter().filter_map(|x| self.y(x).map(|y| (x, y))).collect(); + } + let steps = 48; + (0..=steps) + .filter_map(|i| { + let x = x0 + (x1 - x0) * i as f64 / steps as f64; + self.y(x).map(|y| (x, y)) + }) + .collect() + } + + /// Data x plus forecast, in category (or scatter x) units. Forecast is capped so a hostile + /// value cannot walk off the chart. + pub fn span(&self, forward: Option, backward: Option) -> (f64, f64) { + let pad = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(24.0)).unwrap_or(0.0); + if matches!(self.kind, TrendlineType::MovingAvg) { (self.x_min, self.x_max) } else { (self.x_min - pad(backward), self.x_max + pad(forward)) } + } + + pub fn equation(&self) -> Option { + let body = match &self.model { + Model::Poly(c) => poly_equation(c), + Model::Exp { c, b } => Some(format!("y = {}e^({}x)", num(*c), num(*b))), + Model::Log { b, c } => poly_equation(&[*b, *c]).map(|s| s.replacen("x", "ln(x)", 1)), + Model::Power { c, b } => { + let mag = if (c.abs() - 1.0).abs() < 1e-6 { String::new() } else { num(c.abs()) }; + let sign = if *c < 0.0 { "-" } else { "" }; + Some(format!("y = {sign}{mag}{}", x_pow(*b))) + } + Model::Moving(_) => None, + }?; + Some(body) + } + + pub fn r2_label(&self) -> String { + let r = if self.r2.is_finite() { self.r2 } else { 0.0 }; + if (r - 1.0).abs() < 5e-4 { "R² = 1".to_string() } else { format!("R² = {r:.3}") } + } +} + +impl Series { + /// Trendline y values, including a short forecast, that sit near this series. The axis uses + /// them so a forecast is not clipped off the plot. A value many times the series' own range + /// is left out, so a wild polynomial cannot flatten the chart. + pub fn trendline_ys(&self, scatter: bool) -> Vec { + let n = self.values.len().min(10_000); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for i in 0..n { + let Some(y) = self.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + let x = if scatter { + let Some(x) = self.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + x + } else { + (i + 1) as f64 + }; + xs.push(x); + ys.push(y); + } + let (data_lo, data_hi) = ys.iter().fold((f64::MAX, f64::MIN), |(lo, hi), y| (lo.min(*y), hi.max(*y))); + if !data_lo.is_finite() || !data_hi.is_finite() { + return Vec::new(); + } + let span = (data_hi - data_lo).abs().max(1.0); + let mid = (data_lo + data_hi) / 2.0; + let mut out = Vec::new(); + for t in self.trendlines.iter().take(8) { + let Some(fit) = t.fit(&xs, &ys) else { continue }; + let (x0, x1) = fit.span(t.forward, t.backward); + for (_, y) in fit.samples(x0, x1) { + if y.is_finite() && (y - mid).abs() <= span * 8.0 { + out.push(y); + } + } + } + out + } +} + +/// Whether a series is plotted against numeric x values rather than category positions. +pub fn series_is_scatter(kind: ChartType) -> bool { + matches!(kind, ChartType::Scatter | ChartType::Bubble) +} + +fn split(pairs: &[(f64, f64)]) -> (Vec, Vec) { + pairs.iter().map(|(x, y)| (*x, *y)).unzip() +} + +/// `y = b + m x`. `intercept` fixes `b`. +fn linreg(xs: &[f64], ys: &[f64], intercept: Option) -> Option<(f64, f64)> { + if xs.len() != ys.len() || xs.len() < 2 { + return None; + } + if let Some(b) = intercept { + let mut sxx = 0.0; + let mut sxy = 0.0; + for (x, y) in xs.iter().zip(ys) { + sxx += x * x; + sxy += x * (y - b); + } + if sxx <= 0.0 || !sxx.is_finite() { + return None; + } + let m = sxy / sxx; + return (m.is_finite() && b.is_finite()).then_some((b, m)); + } + let n = xs.len() as f64; + let mx = xs.iter().sum::() / n; + let my = ys.iter().sum::() / n; + let (mut sxx, mut sxy) = (0.0, 0.0); + for (x, y) in xs.iter().zip(ys) { + let dx = x - mx; + sxx += dx * dx; + sxy += dx * (y - my); + } + // Constant x with a large offset leaves rounding residue in `mx`, so compare against the + // rounding scale of the mean instead of zero. + if sxx <= 4.0 * n * (f64::EPSILON * mx.abs()).powi(2) || !sxx.is_finite() { + return None; + } + let m = sxy / sxx; + let b = my - m * mx; + (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 +} + +fn fit_exp(pairs: &[(f64, f64)], intercept: Option) -> Option<(Model, Vec, Vec)> { + let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(_, y)| *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (xs, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln); + let (ln_c, b) = linreg(&xs, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = xs.iter().map(|x| c * (b * x).exp()).collect(); + Some((Model::Exp { c, b }, ys, yhat)) +} + +fn fit_power(pairs: &[(f64, f64)], intercept: Option) -> Option<(Model, Vec, Vec)> { + let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (ln_x, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln); + let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = pairs.iter().map(|(x, _)| c * x.powf(b)).collect(); + Some((Model::Power { c, b }, ys, yhat)) +} + +fn fit_moving_avg(pairs: &[(f64, f64)], period: usize) -> Option<(Model, Vec, Vec)> { + if pairs.len() <= period { + return None; + } + let mut pts = Vec::new(); + let mut ys = Vec::new(); + let mut yhat = Vec::new(); + for i in period - 1..pairs.len() { + let window = &pairs[i + 1 - period..=i]; + let avg = window.iter().map(|(_, y)| y).sum::() / period as f64; + if avg.is_finite() { + pts.push((pairs[i].0, avg)); + ys.push(pairs[i].1); + yhat.push(avg); + } + } + if pts.len() < 2 { + return None; + } + Some((Model::Moving(pts), ys, yhat)) +} + +#[allow(clippy::needless_range_loop)] +fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option> { + // Fit `y - b0 = (x / sx) * q(u)` with `u = (x - xm) / su` and `q` of degree `order - 1`. The + // basis `(x / sx) * u^j` spans the same polynomials as `x^(j + 1)`, but keeps the pivots in + // `solve` independent of the units and the offset of x. + let n = xs.len() as f64; + let sx = xs.iter().fold(0.0_f64, |acc, x| acc.max(x.abs())); + if sx <= 0.0 || !sx.is_finite() { + return None; + } + let xm = xs.iter().sum::() / n; + let su = match xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs())) { + s if s > 0.0 && s.is_finite() => s, + _ => 1.0, + }; + 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 v = x / sx; + let u = (x - xm) / su; + let dy = y - b0; + for i in 0..m { + let xi = v * u.powi(i as i32); + atb[i] += dy * xi; + for j in i..m { + ata[i][j] += xi * v * u.powi(j as i32); + } + } + } + for i in 0..m { + for j in 0..i { + ata[i][j] = ata[j][i]; + } + } + let centred: Vec = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / su.powi(k as i32)).collect(); + let q = expand_shift(¢red, xm); + let mut coeff = Vec::with_capacity(order + 1); + coeff.push(b0); + coeff.extend(q.iter().map(|c| c / sx)); + coeff.iter().all(|v| v.is_finite()).then_some(coeff) +} + +/// Polynomial through the normal equations. `x` is centred when the intercept is free, then +/// expanded back to powers of `x`, so order 6 on category numbers stays solvable. +#[allow(clippy::needless_range_loop)] // the index is the power in the normal equations +fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option) -> Option> { + let n = xs.len(); + if n != ys.len() || !(1..=6).contains(&order) || n < order + 1 { + return None; + } + if let Some(b0) = intercept { + return polyfit_with_intercept(xs, ys, order, b0); + } + let xm = xs.iter().sum::() / n as f64; + // Scale the centred x to unit range so the pivot threshold in `solve` does not depend on the + // units of x. + let s = xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs())); + if s <= 0.0 || !s.is_finite() { + return None; + } + let us: Vec = xs.iter().map(|x| (x - xm) / s).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: Vec = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / s.powi(k as i32)).collect(); + let coeff = expand_shift(¢red, xm); + coeff.iter().all(|v| v.is_finite()).then_some(coeff) +} + +/// `a[k]` is the coefficient of `(x - xm)^k`. Return coefficients of powers of `x`. +#[allow(clippy::needless_range_loop)] // j is the power of x after the binomial shift +fn expand_shift(a: &[f64], xm: f64) -> Vec { + let mut c = vec![0.0; a.len()]; + for (k, ak) in a.iter().enumerate() { + let mut binom = 1.0; + for j in 0..=k { + c[j] += ak * binom * (-xm).powi((k - j) as i32); + if j < k { + binom *= (k - j) as f64 / (j + 1) as f64; + } + } + } + c +} + +#[allow(clippy::needless_range_loop)] // Gaussian elimination walks rows and columns by index +fn solve(mut a: Vec>, mut b: Vec) -> Option> { + let n = b.len(); + if n == 0 || n > 8 || a.len() != n { + return None; + } + for col in 0..n { + let mut piv = col; + for r in col + 1..n { + if a[r][col].abs() > a[piv][col].abs() { + piv = r; + } + } + if a[piv][col].abs() < 1e-12 { + return None; + } + a.swap(col, piv); + b.swap(col, piv); + let div = a[col][col]; + for c in col..n { + a[col][c] /= div; + } + b[col] /= div; + for r in 0..n { + if r == col { + continue; + } + let f = a[r][col]; + if f == 0.0 { + continue; + } + for c in col..n { + a[r][c] -= f * a[col][c]; + } + b[r] -= f * b[col]; + } + } + b.iter().all(|v| v.is_finite()).then_some(b) +} + +fn r_squared(ys: &[f64], yhat: &[f64]) -> f64 { + if ys.is_empty() || ys.len() != yhat.len() { + return 0.0; + } + let mean = ys.iter().sum::() / ys.len() as f64; + let mut ss_tot = 0.0; + let mut ss_res = 0.0; + for (y, h) in ys.iter().zip(yhat) { + ss_tot += (y - mean) * (y - mean); + ss_res += (y - h) * (y - h); + } + if ss_tot < 1e-18 { + return if ss_res < 1e-12 { 1.0 } else { 0.0 }; + } + let r = 1.0 - ss_res / ss_tot; + if r.is_finite() { r } else { 0.0 } +} + +fn num(v: f64) -> String { + if !v.is_finite() { + return "0".to_string(); + } + let a = v.abs(); + if a == 0.0 { + return "0".to_string(); + } + if !(1.0e-3..1.0e6).contains(&a) { + 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 { + // Drop terms that are negligible relative to the largest coefficient, so tiny-valued data keeps its terms. + let scale = coeff.iter().map(|c| c.abs()).fold(0.0, f64::max); + 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.max(1.0) * 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_fits_tiny_x_values() { + let xs = [1e-10, 2e-10, 3e-10, 4e-10, 5e-10]; + let ys = [1.0, 2.0, 3.0, 4.0, 5.0]; + let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of tiny x values"); + assert!((fit.y(3e-10).unwrap() - 3.0).abs() < 1e-6); + } + + #[test] + fn poly_fits_tiny_x_values() { + let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5]; + let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x; + let ys: Vec = xs.iter().map(|x| f(*x)).collect(); + let fit = line(TrendlineType::Poly).fit(&xs, &ys).expect("poly fit of tiny x values"); + assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9); + let big: Vec = xs.iter().map(|x| x * 1e5).collect(); + let scaled = line(TrendlineType::Poly).fit(&big, &ys).expect("poly fit of scaled x values"); + for x in xs { + assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}"); + } + } + + #[test] + fn poly_with_intercept_fits_tiny_x_values() { + let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5]; + let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x; + let ys: Vec = xs.iter().map(|x| f(*x)).collect(); + let mut poly = line(TrendlineType::Poly); + poly.intercept = Some(1.0); + let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of tiny x values"); + assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9); + let big: Vec = xs.iter().map(|x| x * 1e5).collect(); + let scaled = poly.fit(&big, &ys).expect("poly fit with intercept of scaled x values"); + for x in xs { + assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}"); + } + } + + #[test] + fn poly_with_intercept_fits_offset_but_narrow_x_range() { + let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04]; + let f = |x: f64| 1.0 + x * (1.0 + 100.0 * (x - 45000.02)); + let ys: Vec = xs.iter().map(|x| f(*x)).collect(); + let mut poly = line(TrendlineType::Poly); + poly.intercept = Some(1.0); + let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of offset x values"); + for x in xs { + assert!((fit.y(x).unwrap() - f(x)).abs() < 1e-2, "{x}"); + } + } + + #[test] + fn linear_fits_offset_but_narrow_x_range() { + let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04]; + let ys = [1.0, 2.0, 3.0, 4.0, 5.0]; + let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of offset x values"); + assert!((fit.y(45000.02).unwrap() - 3.0).abs() < 1e-4); + let xs = [1e7, 1e7 + 1.0, 1e7 + 2.0, 1e7 + 3.0, 1e7 + 4.0]; + let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of 1e7 offset"); + assert!((fit.y(1e7 + 2.0).unwrap() - 3.0).abs() < 1e-4); + } + + #[test] + fn linear_fixed_intercept_on_offset_narrow_x_range() { + let xs = [1e7, 1e7 + 1.0, 1e7 + 2.0, 1e7 + 3.0, 1e7 + 4.0]; + let ys: Vec = xs.iter().map(|x| 2.0 * x).collect(); + let mut forced = line(TrendlineType::Linear); + forced.intercept = Some(0.0); + let fit = forced.fit(&xs, &ys).expect("fixed-intercept fit"); + assert!((fit.y(1e7 + 2.0).unwrap() / (2.0 * (1e7 + 2.0)) - 1.0).abs() < 1e-9); + } + + #[test] + fn linear_fixed_intercept_rejects_overflowing_x() { + let xs = [1e160, 2e160, 3e160]; + let ys = [1.0, 2.0, 3.0]; + let mut forced = line(TrendlineType::Linear); + forced.intercept = Some(0.0); + assert!(forced.fit(&xs, &ys).is_none()); + } + + #[test] + fn linear_rejects_constant_x_with_large_offset() { + let xs = [45000.1; 5]; + let ys = [1.0, 2.0, 3.0, 4.0, 5.0]; + assert!(line(TrendlineType::Linear).fit(&xs, &ys).is_none()); + } + + #[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"); + } + + #[test] + fn equation_keeps_tiny_coefficients() { + let xs = [1.0, 2.0, 3.0, 4.0]; + let lin = line(TrendlineType::Linear).fit(&xs, &[2e-9, 4e-9, 6e-9, 8e-9]).expect("linear"); + assert_eq!(lin.equation().as_deref(), Some("y = 2.00e-9x")); + let mut poly = line(TrendlineType::Poly); + poly.order = Some(2); + let ys: Vec = xs.iter().map(|x| 3e-9 * x * x + 1e-9).collect(); + let quad = poly.fit(&xs, &ys).expect("poly"); + assert_eq!(quad.equation().as_deref(), Some("y = 3.00e-9x² + 1.00e-9")); + let ys: Vec = xs.iter().map(|x: &f64| 5e-9 * x.ln()).collect(); + let log = line(TrendlineType::Log).fit(&xs, &ys).expect("log"); + assert_eq!(log.equation().as_deref(), Some("y = 5.00e-9ln(x)")); + let zero = line(TrendlineType::Linear).fit(&xs, &[0.0; 4]).expect("zero"); + assert_eq!(zero.equation().as_deref(), Some("y = 0")); + } +}