From b8ddca30a1f35b2b3749aa0dddbb0ab0f0a86619 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:36:30 +0000 Subject: [PATCH] refactor(docx): split read/chart.rs into read/chart/ --- crates/docx/src/read/chart.rs | 1674 +------------------------- crates/docx/src/read/chart/canvas.rs | 210 ++++ crates/docx/src/read/chart/format.rs | 370 ++++++ crates/docx/src/read/chart/model.rs | 86 ++ crates/docx/src/read/chart/parse.rs | 336 ++++++ crates/docx/src/read/chart/plot.rs | 463 +++++++ crates/docx/src/read/chart/polar.rs | 206 ++++ 7 files changed, 1693 insertions(+), 1652 deletions(-) create mode 100644 crates/docx/src/read/chart/canvas.rs create mode 100644 crates/docx/src/read/chart/format.rs create mode 100644 crates/docx/src/read/chart/model.rs create mode 100644 crates/docx/src/read/chart/parse.rs create mode 100644 crates/docx/src/read/chart/plot.rs create mode 100644 crates/docx/src/read/chart/polar.rs diff --git a/crates/docx/src/read/chart.rs b/crates/docx/src/read/chart.rs index fd13f058a..385d3af0a 100644 --- a/crates/docx/src/read/chart.rs +++ b/crates/docx/src/read/chart.rs @@ -16,6 +16,28 @@ use super::drawing_color::{fill_colour, slot}; use crate::units::{int, measure}; use crate::xml::El; +mod canvas; +mod format; +mod model; +mod parse; +mod plot; +mod polar; + +#[cfg(test)] +use canvas::SWATCH; +pub(super) use canvas::text_w; +use canvas::{Rect, clip_text, legend, text}; +#[cfg(test)] +use format::{civil, excel_stamp, format_value}; +use model::{Axis, Grouping, Kind, Pos, Series}; +#[cfg(test)] +use parse::{axis, category_text, labels, numbers}; +use parse::{read_series, text_size, title_of}; +#[cfg(test)] +use plot::{Scale, narrow, nice_scale}; +use plot::{backdrop, cartesian}; +use polar::{pie, radar}; + /// Most series read from one chart. const MAX_SERIES: usize = 256; /// Most points read from one series. @@ -132,91 +154,6 @@ fn unsupported_name(name: &str) -> &'static str { } } -/// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in -/// white on the fill) or just beside it (above, or right of a horizontal bar's end). -struct DataLabel { - x: f32, - y: f32, - text: String, - inside: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Kind { - Bar, - Line, - Area, - Pie, - Doughnut, - Scatter, - Radar, -} - -impl Kind { - fn of(name: &str) -> Option { - Some(match name { - "c:barChart" => Kind::Bar, - "c:lineChart" => Kind::Line, - "c:areaChart" => Kind::Area, - "c:pieChart" => Kind::Pie, - "c:doughnutChart" => Kind::Doughnut, - "c:scatterChart" => Kind::Scatter, - "c:radarChart" => Kind::Radar, - _ => return None, - }) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Grouping { - Clustered, - Stacked, - Percent, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Pos { - Right, - Left, - Top, - Bottom, -} - -/// One value axis (or category axis) as the file states it. -#[derive(Default)] -struct Axis { - deleted: bool, - grid: bool, - line: bool, - /// `c:crossBetween val="midCat"`: categories sit on the tick marks, not between them. - mid: bool, - min: Option, - max: Option, - /// Number format of the tick labels, when not linked to the source data. - fmt: Option, -} - -struct Series { - name: String, - color: Option, - line_w: f32, - /// Whether the series is drawn as a line (scatter series without one get markers). - line: bool, - values: Vec>, - /// Scatter x values (index + 1 when the file has none). - xs: Vec>, - /// Per-point colours from `c:dPt`. - points: Vec>, - fmt: Option, - /// Format code of the scatter x values' cache. - x_fmt: Option, - /// Data labels on or off for this series (`c:dLbls` of its own), when it says. - labels: Option, - /// The values as the file has them, for data labels (`values` become shares in a 100% - /// stacked chart); empty when the series shows no labels. - raw: Vec>, -} - struct Chart { kind: Kind, horizontal: bool, @@ -251,1572 +188,5 @@ struct Chart { plot_border: Option, } -impl Chart { - fn parse(space: &El, theme: &[Rgb]) -> Option { - let chart = space.child("c:chart")?; - let plot = chart.child("c:plotArea")?; - let ty = plot.els().find(|e| Kind::of(&e.name).is_some())?; - let kind = Kind::of(&ty.name)?; - let scatter = kind == Kind::Scatter; - let date1904 = space.child("c:date1904").is_some_and(|d| d.attr("val").is_none_or(truthy)); - let mut series = read_series(ty, scatter, theme); - if series.is_empty() { - return None; - } - let cats = ty.children("c:ser").find_map(|s| s.child("c:cat")).map(|c| labels(Some(c), MAX_POINTS, date1904)).unwrap_or_default(); - let n = if kind == Kind::Radar { - series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()).min(MAX_SPOKES) - } else if matches!(kind, Kind::Pie | Kind::Doughnut) { - series.first().map_or(0, |s| s.values.len()).max(cats.len()) - } else { - series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()) - }; - // Every series is padded to `n` points, so keep only as many as the cell budget holds. - if let Some(keep) = MAX_CELLS.checked_div(n) { - series.truncate(keep.max(1)); - } - if kind == Kind::Radar { - series.truncate(MAX_RADAR_SERIES); - } - let grouping = match ty.child("c:grouping").and_then(|g| g.attr("val")) { - Some("stacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Stacked, - Some("percentStacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Percent, - _ => Grouping::Clustered, - }; - let mut cats = cats; - cats.resize(n, String::new()); - let radar_style = ty.child("c:radarStyle").and_then(|r| r.attr("val")); - let type_labels = show_values(ty.child("c:dLbls")); - for s in &mut series { - s.values.resize(n, None); - s.xs.resize(n, None); - s.labels = Some(s.labels.or(type_labels).unwrap_or(false)); - if s.labels == Some(true) { - s.raw = s.values.clone(); - } - } - if grouping == Grouping::Percent { - to_percent(&mut series, n); - } - let (cat, val) = chart_axes(plot, scatter); - let val_fmt = if grouping == Grouping::Percent { - "0%".to_string() - } else { - val.fmt.clone().or_else(|| series.iter().find_map(|s| s.fmt.clone())).unwrap_or_else(|| "General".to_string()) - }; - let num = |n: &str| ty.child(n).and_then(|g| g.attr("val")).and_then(int); - Some(Chart { - kind, - horizontal: kind == Kind::Bar && ty.child("c:barDir").and_then(|d| d.attr("val")) == Some("bar"), - grouping, - gap: num("c:gapWidth").unwrap_or(150).clamp(0, 500) as f32 / 100.0, - overlap: num("c:overlap").unwrap_or(0).clamp(-100, 100) as f32 / 100.0, - hole: num("c:holeSize").unwrap_or(50).clamp(10, 90) as f32 / 100.0, - first_angle: num("c:firstSliceAng").unwrap_or(0).clamp(0, 360) as f64, - radar_fill: radar_style == Some("filled"), - radar_marks: radar_style == Some("marker"), - vary: matches!(kind, Kind::Pie | Kind::Doughnut) && ty.child("c:varyColors").and_then(|v| v.attr("val")).is_none_or(truthy), - n, - cats, - title: title_of(chart, &series), - series, - accents: (4..10).map(|i| slot(theme, i)).collect(), - legend: legend_pos(chart), - cat, - val, - text_size: text_size(space), - val_fmt, - date1904, - plot_border: plot.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| fill_colour(l, theme)), - }) - } - - fn stacked(&self) -> bool { - self.grouping != Grouping::Clustered - } - - fn accent(&self, i: usize) -> Rgb { - self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK) - } - - fn series_colour(&self, j: usize) -> Rgb { - self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j)) - } - - /// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or - /// the series colour. - fn point_colour(&self, j: usize, i: usize) -> Rgb { - if let Some(Some(c)) = self.series.get(j).and_then(|s| s.points.get(i)) { - return *c; - } - if self.vary { self.accent(i) } else { self.series_colour(j) } - } - - fn legend_entries(&self) -> Vec<(String, Rgb)> { - if matches!(self.kind, Kind::Pie | Kind::Doughnut) { - return self - .cats - .iter() - .enumerate() - .map(|(i, c)| (if c.is_empty() { (i + 1).to_string() } else { c.clone() }, self.point_colour(0, i))) - .collect(); - } - self.series - .iter() - .enumerate() - .map(|(j, s)| (if s.name.is_empty() { format!("Series {}", j + 1) } else { s.name.clone() }, self.series_colour(j))) - .collect() - } -} - -/// The series of a chart type element, within the chart's cell budget. -fn read_series(ty: &El, scatter: bool, theme: &[Rgb]) -> Vec { - let mut budget = MAX_CELLS; - let mut series = Vec::new(); - for ser in ty.children("c:ser").take(MAX_SERIES) { - let s = Series::parse(ser, scatter, theme, budget.min(MAX_POINTS)); - budget = budget.saturating_sub(s.values.len().max(s.xs.len())); - series.push(s); - } - series -} - -/// Values as their share of each category's total (100% stacked charts). -fn to_percent(series: &mut [Series], n: usize) { - for i in 0..n { - let total: f64 = series.iter().map(|s| s.values.get(i).copied().flatten().map_or(0.0, f64::abs)).sum(); - for s in series.iter_mut() { - if let Some(Some(v)) = s.values.get_mut(i) { - *v = if total > 0.0 { *v / total } else { 0.0 }; - } - } - } -} - -/// The category axis (scatter: the horizontal value axis) and the value axis of a plot area. -fn chart_axes(plot: &El, scatter: bool) -> (Axis, Axis) { - let vals_ax: Vec<&El> = plot.children("c:valAx").collect(); - let horiz_pos = |a: &&El| matches!(a.child("c:axPos").and_then(|p| p.attr("val")), Some("b" | "t")); - let (val_el, x_el) = if scatter { - let x = vals_ax.iter().copied().find(horiz_pos).or_else(|| vals_ax.first().copied()); - let y = vals_ax.iter().copied().find(|a| !horiz_pos(a)).or_else(|| vals_ax.iter().copied().find(|a| Some(*a) != x)); - (y, x) - } else { - (vals_ax.first().copied(), None) - }; - let cat_el = plot.els().find(|e| matches!(e.name.as_str(), "c:catAx" | "c:dateAx")); - (axis(if scatter { x_el } else { cat_el }), axis(val_el)) -} - -/// The chart's title. An empty `c:title` shows the series name (Word's automatic title of a -/// one-series chart) unless the automatic title is deleted. -fn title_of(chart: &El, series: &[Series]) -> Option { - let auto_deleted = chart.child("c:autoTitleDeleted").and_then(|a| a.attr("val")).is_some_and(truthy); - let auto = if !auto_deleted && series.len() == 1 { series.first().map(|s| s.name.clone()).filter(|s| !s.is_empty()) } else { None }; - chart.child("c:title").and_then(|t| title_text(t).or(auto)) -} - -/// Whether a `c:dLbls` shows the values (`None` when there's none, or it doesn't say). -fn show_values(d: Option<&El>) -> Option { - let d = d?; - if d.child("c:delete").and_then(|e| e.attr("val")).is_some_and(truthy) { - return Some(false); - } - d.child("c:showVal").map(|v| v.attr("val").is_none_or(truthy)) -} - -fn legend_pos(chart: &El) -> Option { - chart.child("c:legend").map(|l| match l.child("c:legendPos").and_then(|p| p.attr("val")).unwrap_or("r") { - "l" => Pos::Left, - "t" | "tr" => Pos::Top, - "b" => Pos::Bottom, - _ => Pos::Right, - }) -} - -/// The chart's text size (its `c:txPr` default run size), points. -fn text_size(space: &El) -> f32 { - space - .child("c:txPr") - .and_then(|t| t.find("a:defRPr")) - .and_then(|d| d.attr("sz")) - .and_then(int) - .map_or(FONT, |v| (v as f32 / 100.0).clamp(6.0, 24.0)) -} - -impl Series { - fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series { - let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default(); - let sp = ser.child("c:spPr"); - let ln = sp.and_then(|p| p.child("a:ln")); - let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme)); - let color = sp.and_then(|p| fill_colour(p, theme)).or_else(|| ln.and_then(|l| fill_colour(l, theme))).or(marker); - let line_w = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).map_or(LINE_W, |w| w.clamp(0.0, 20.0)); - let line = ln.is_none_or(|l| l.child("a:noFill").is_none()); - let (values, fmt) = numbers(ser.child(if scatter { "c:yVal" } else { "c:val" }), cap); - let (mut xs, x_fmt) = if scatter { numbers(ser.child("c:xVal"), cap) } else { (Vec::new(), None) }; - if scatter && xs.is_empty() { - xs = (1..=values.len()).map(|i| Some(i as f64)).collect(); - } - let mut points = vec![None; values.len()]; - for dpt in ser.children("c:dPt") { - let i = dpt.child("c:idx").and_then(|e| e.attr("val")).and_then(int).and_then(|i| usize::try_from(i).ok()); - let c = dpt.child("c:spPr").and_then(|p| fill_colour(p, theme)); - if let Some(slot) = i.and_then(|i| points.get_mut(i)) { - *slot = c; - } - } - let labels = show_values(ser.child("c:dLbls")); - Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() } - } -} - -/// Longest number-format code honoured (the limit spreadsheet apps use); longer ones are ignored. -const MAX_FORMAT_CODE: usize = 255; - -/// Category labels of a `c:cat` (string or number cache), capped. Numbers show in the cache's -/// format code (dates included); text and `General` numbers as they are stored. -fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec { - let Some(el) = el else { return Vec::new() }; - let Some(cache) = el.find("c:strCache").or_else(|| el.find("c:numCache")).or_else(|| el.find("c:strLit")).or_else(|| el.find("c:numLit")) else { - return Vec::new(); - }; - let code = - cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE && !f.eq_ignore_ascii_case("General")); - pts(cache, cap) - .into_iter() - .map(|p| { - let text = p.unwrap_or_default(); - match (code.as_deref(), text.trim().parse::()) { - (Some(code), Ok(v)) if v.is_finite() => label(&category_text(v, code, 1.0, date1904)), - _ => label(&text), - } - }) - .collect() -} - -/// A number in its format code: a date or time for date codes (serial days, from 1904 when -/// `date1904`), else as [`format_value`] with tick step `step`. -fn category_text(v: f64, code: &str, step: f64, date1904: bool) -> String { - match excel_stamp(v, date1904) { - Some(t) if is_date_code(code) => date_text(t, code), - _ => format_value(v, code, step), - } -} - -/// Values of a `c:val`-style element (number cache or literal), capped; with its format code. -fn numbers(el: Option<&El>, cap: usize) -> (Vec>, Option) { - let Some(cache) = el.and_then(|e| e.find("c:numCache").or_else(|| e.find("c:numLit"))) else { - return (Vec::new(), None); - }; - let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE); - let vals = pts(cache, cap).into_iter().map(|p| p.and_then(|t| t.trim().parse::().ok()).filter(|v| v.is_finite())).collect(); - (vals, fmt) -} - -/// The `c:pt` texts of a cache by index. Indexes and counts from the file are capped. -fn pts(cache: &El, cap: usize) -> Vec> { - let declared = cache.child("c:ptCount").and_then(|c| c.attr("val")).and_then(int).unwrap_or(0).clamp(0, cap as i64) as usize; - let mut out: Vec> = vec![None; declared]; - for p in cache.children("c:pt") { - let Some(i) = p.attr("idx").and_then(int).and_then(|i| usize::try_from(i).ok()) else { continue }; - if i >= cap { - continue; - } - if out.len() <= i { - out.resize(i + 1, None); - } - if let Some(slot) = out.get_mut(i) { - *slot = p.child("c:v").map(|v| v.text()); - } - } - out -} - -fn axis(a: Option<&El>) -> Axis { - let Some(a) = a else { return Axis { deleted: true, ..Default::default() } }; - let scaling = a.child("c:scaling"); - let num = - |n: &str| scaling.and_then(|s| s.child(n)).and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); - Axis { - deleted: a.child("c:delete").and_then(|d| d.attr("val")).is_some_and(truthy), - grid: a.child("c:majorGridlines").is_some(), - line: a.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| l.child("a:noFill")).is_none(), - mid: a.child("c:crossBetween").and_then(|c| c.attr("val")) == Some("midCat"), - min: num("c:min"), - max: num("c:max"), - fmt: a - .child("c:numFmt") - .filter(|f| f.attr("sourceLinked") != Some("1")) - .and_then(|f| f.attr("formatCode")) - .filter(|f| f.len() <= MAX_FORMAT_CODE) - .map(str::to_string), - } -} - -fn title_text(t: &El) -> Option { - let tx = t.child("c:tx")?; - let s = match tx.child("c:rich") { - Some(rich) => { - let mut s = String::new(); - rich_into(rich, &mut s); - s - } - None => tx.find("c:v").map(|v| v.text()).unwrap_or_default(), - }; - let s = label(s.trim()); - (!s.is_empty()).then_some(s) -} - -/// The `a:t` runs under an element, in order. -fn rich_into(e: &El, s: &mut String) { - for c in e.els() { - if c.name == "a:t" { - s.push_str(&c.text()); - } else { - rich_into(c, s); - } - } -} - -fn truthy(v: &str) -> bool { - matches!(v.trim(), "1" | "true") -} - -fn label(s: &str) -> String { - s.chars().take(MAX_LABEL).collect() -} - -/// Chart area: the chart space's fill and border (white and borderless by default). -fn backdrop(out: &mut Vec, space: &El, theme: &[Rgb], w: f32, h: f32) { - let sp = space.child("c:spPr"); - let fill = match sp { - Some(p) if p.child("a:noFill").is_some() => None, - Some(p) => fill_colour(p, theme).or(Some(Rgb::WHITE)), - None => Some(Rgb::WHITE), - }; - let ln = sp.and_then(|p| p.child("a:ln")); - let stroke = ln.and_then(|l| fill_colour(l, theme)); - let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.75).clamp(0.0, 20.0); - if fill.is_some() || stroke.is_some() { - out.push(GraphicItem::Shape { rect: [0.0, 0.0, w, h], kind: ShapeKind::Rectangle, fill, stroke, stroke_width }); - } -} - -/// An axis scale: `lo` to `hi` in steps of `step`. -#[derive(Clone, Copy, Debug, PartialEq)] -struct Scale { - lo: f64, - hi: f64, - step: f64, -} - -impl Scale { - fn ticks(&self) -> Vec { - let n = (((self.hi - self.lo) / self.step + 1e-9).floor().max(0.0) as usize).min(MAX_TICKS); - (0..=n).map(|k| self.lo + k as f64 * self.step).filter(|t| *t <= self.hi + 1e-9).collect() - } -} - -/// A scale for data `dlo..dhi` (zero included when `zero`), as Excel and Word pick it: an end -/// away from zero sits 5% of the data span beyond the data (an end at zero stays there), rounded -/// out to a step of 1, 2, 2.5, 5 or 10 times a power of ten (about seven steps). `min`/`max` -/// from the file replace the ends. -fn nice_scale(dlo: f64, dhi: f64, zero: bool, min: Option, max: Option) -> Scale { - let (dlo, dhi) = if dhi > dlo { (dlo, dhi) } else { (dlo, dlo + 1.0) }; - let span = dhi - dlo; - // With zero, the ends reach it: all-negative data still starts its bars at zero. - let (ends_lo, ends_hi) = if zero { (dlo.min(0.0), dhi.max(0.0)) } else { (dlo, dhi) }; - let lo = min.unwrap_or(if ends_lo < 0.0 { ends_lo - 0.05 * span } else { ends_lo }); - let hi = max.unwrap_or(if ends_hi > 0.0 { ends_hi + 0.05 * span } else { ends_hi }); - if !(hi > lo && hi.is_finite() && lo.is_finite()) { - return Scale { lo: 0.0, hi: 1.0, step: 0.2 }; - } - let raw = (hi - lo) / 7.5; - let mag = 10f64.powf(raw.log10().floor()); - let step = [1.0, 2.0, 2.5, 5.0, 10.0].iter().map(|m| m * mag).find(|s| *s >= raw).unwrap_or(10.0 * mag); - let lo_r = if min.is_some() { lo } else { ((lo / step) + 1e-9).floor() * step }; - let hi_r = if max.is_some() { hi } else { ((hi / step) - 1e-9).ceil() * step }; - if !(step.is_finite() && step > 0.0 && lo_r.is_finite() && hi_r.is_finite() && hi_r > lo_r) { - return Scale { lo: 0.0, hi: 1.0, step: 0.2 }; - } - Scale { lo: lo_r, hi: hi_r, step } -} - -/// A piece of a format section: literal text, a number character (`#`, `0`, `,`, `.`), or the -/// exponent of a scientific format (`E+` when true, `E-` when false). -enum Tok { - Lit(String), - Num(char), - Exp(bool), -} - -/// The sections of a format code (positive; negative; zero; text), split outside quotes. -fn sections(code: &str) -> Vec<&str> { - let mut out = Vec::new(); - let (mut start, mut quoted, mut escaped) = (0, false, false); - for (i, c) in code.char_indices() { - match c { - _ if escaped => escaped = false, - '\\' if !quoted => escaped = true, - '"' => quoted = !quoted, - ';' if !quoted => { - out.push(code.get(start..i).unwrap_or("")); - start = i + 1; - } - _ => {} - } - } - out.push(code.get(start..).unwrap_or("")); - out -} - -/// The first section of a format code. -fn first_section(code: &str) -> &str { - sections(code).first().copied().unwrap_or("") -} - -/// The pieces of one format section. Quoted text, escapes and `[$€-407]` currency symbols are -/// literal; other brackets (`[Red]`) are dropped; `_x` (room for `x`) is a space; `*x` is dropped. -fn format_toks(section: &str) -> Vec { - let mut toks = Vec::new(); - let mut chars = section.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '"' => toks.push(Tok::Lit(chars.by_ref().take_while(|q| *q != '"').collect())), - '[' => { - let inner: String = chars.by_ref().take_while(|q| *q != ']').collect(); - if let Some(sym) = currency(&inner) { - toks.push(Tok::Lit(sym.to_string())); - } - } - '\\' => toks.extend(chars.next().map(|n| Tok::Lit(n.to_string()))), - '_' => { - chars.next(); - toks.push(Tok::Lit(" ".to_string())); - } - '*' => { - chars.next(); - } - 'E' | 'e' if matches!(chars.peek(), Some('+' | '-')) => toks.push(Tok::Exp(chars.next() == Some('+'))), - '#' | '0' | ',' | '.' => toks.push(Tok::Num(c)), - c => toks.push(Tok::Lit(c.to_string())), - } - } - toks -} - -/// The symbol of a `$€-407` bracket content (`[$€-407]`), if it is one. -fn currency(inner: &str) -> Option<&str> { - inner.strip_prefix('$').map(|rest| rest.split('-').next().unwrap_or("")) -} - -/// Tick text for `v` in a number format code (`#,##0`, `0.0`, `0%`, `0.00E+00`, and literal text -/// such as `"€"0.00` or `#,##0.00 [$€-407]`). A negative number takes the second section when -/// there is one, which shows it without a minus (`#,##0;(#,##0)`). `General` shows as many -/// decimals as the step needs. -fn format_value(v: f64, code: &str, step: f64) -> String { - let v = if v.abs() < step * 1e-9 { 0.0 } else { v }; - let secs = sections(code); - match secs.get(1) { - Some(neg) if v < 0.0 => format_section(-v, neg, step), - _ => format_section(v, secs.first().copied().unwrap_or(""), step), - } -} - -/// `v` in one section of a format code. -fn format_section(v: f64, section: &str, step: f64) -> String { - let toks = format_toks(section); - let is_num = |t: &Tok| matches!(t, Tok::Num(_) | Tok::Exp(_)); - let lit = |r: &[Tok]| -> String { - r.iter() - .filter_map(|t| match t { - Tok::Lit(l) => Some(l.as_str()), - _ => None, - }) - .collect() - }; - let (Some(s), Some(e)) = (toks.iter().position(is_num), toks.iter().rposition(is_num)) else { return general(v, step) }; - let body = toks.get(s..=e).unwrap_or(&[]); - let (pre, suf) = (lit(toks.get(..s).unwrap_or(&[])), lit(toks.get(e + 1..).unwrap_or(&[]))); - let digits = |r: &[Tok]| -> String { r.iter().filter_map(|t| if let Tok::Num(c) = t { Some(*c) } else { None }).collect() }; - if let Some(k) = body.iter().position(|t| matches!(t, Tok::Exp(_))) { - let plus = matches!(body.get(k), Some(Tok::Exp(true))); - let mantissa = digits(body.get(..k).unwrap_or(&[])); - let exp_digits = digits(body.get(k + 1..).unwrap_or(&[])).len(); - return format!("{pre}{}{suf}", scientific(v, decimals(&mantissa), exp_digits, plus)); - } - let body = digits(body); - let pct = pre.contains('%') || suf.contains('%'); - let v = if pct { v * 100.0 } else { v }; - let digits = format!("{:.*}", decimals(&body), v.abs()); - let (ip, fp) = match digits.split_once('.') { - Some((a, b)) => (a.to_string(), format!(".{b}")), - None => (digits.clone(), String::new()), - }; - let mut grouped = String::new(); - for (k, ch) in ip.chars().enumerate() { - if body.contains(',') && k > 0 && (ip.len() - k) % 3 == 0 { - grouped.push(','); - } - grouped.push(ch); - } - let sign = if v < 0.0 && digits.chars().any(|c| c != '0' && c != '.') { "-" } else { "" }; - format!("{sign}{pre}{grouped}{fp}{suf}") -} - -/// Decimal places of a number body (`#,##0.00` has 2). -fn decimals(body: &str) -> usize { - body.split_once('.').map_or(0, |(_, d)| d.chars().filter(|c| matches!(c, '0' | '#')).count()).min(12) -} - -/// `v` with no number format (`General`): as many decimals as the tick step needs. -fn general(v: f64, step: f64) -> String { - let mut d = 0; - while d < 6 && (step * 10f64.powi(d)).fract().abs() > 1e-9 { - d += 1; - } - let s = format!("{:.*}", d as usize, v); - if s.trim_start_matches(['-', '0', '.']).is_empty() { s.trim_start_matches('-').to_string() } else { s } -} - -/// `v` in scientific notation: `decimals` mantissa decimals, at least `exp_digits` exponent -/// digits, and a `+` on positive exponents when `plus` (`0.00E+00`: 1.23E+04). -fn scientific(v: f64, decimals: usize, exp_digits: usize, plus: bool) -> String { - let s = format!("{:.*e}", decimals, v.abs()); - let (mantissa, exp) = s.split_once('e').unwrap_or((s.as_str(), "0")); - let exp: i32 = if v == 0.0 { 0 } else { exp.parse().unwrap_or(0) }; - let sign = if exp < 0 { - "-" - } else if plus { - "+" - } else { - "" - }; - let minus = if v < 0.0 { "-" } else { "" }; - format!("{minus}{mantissa}E{sign}{:0w$}", exp.unsigned_abs(), w = exp_digits.clamp(1, 4)) -} - -/// A moment as Excel shows a serial day number. -#[derive(Clone, Copy, Debug, PartialEq)] -struct Stamp { - y: i64, - m: u32, - d: u32, - /// Day of the week, 0 = Sunday. - weekday: u32, - /// Seconds into the day. - secs: u32, -} - -/// The moment of Excel serial day `serial` as Excel shows it, from 0 ("January 0, 1900") to -/// 31 December 9999. In the 1900 system serial 60 is Excel's phantom 29 February 1900, and -/// serials before it count from 31 December 1899; in the 1904 system (`date1904`) serial 0 is -/// 1 January 1904. `None` outside that range. -fn excel_stamp(serial: f64, date1904: bool) -> Option { - let serial = if date1904 { serial + 1462.0 } else { serial }; - if !(0.0..2_958_466.0).contains(&serial) { - return None; - } - let whole = serial.floor(); - let secs = (((serial - whole) * 86_400.0).round() as u32).min(86_399); - let day = whole as i64; - let (y, m, d) = match day { - 0 => (1900, 1, 0), - 60 => (1900, 2, 29), - 1..=59 => civil(day + 1), - _ => civil(day), - }; - // Excel's weekdays count serial 1 as a Sunday. - Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs }) -} - -/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (an Excel serial -/// day number). Serial `n` is `n - 25_569` days from 1970-01-01. -/// -/// The caller's serial guard keeps `n` inside chrono's range, but the Gregorian calendar repeats -/// every 400 years (146_097 days), so this stays exact for any `n` without hand-rolled calendar -/// math: chrono converts the day within its cycle and whole cycles go back into the year (an -/// i128 so the extremes can't overflow). 1970-01-01 is `num_days_from_ce` 719_163, so serial `n` -/// is CE day `n - 25_569 + 719_163`. -fn civil(n: i64) -> (i64, u32, u32) { - use chrono::Datelike; - // CE day of serial `n`, in i128 so no `n` overflows. - let from_ce = i128::from(n) - 25_569 + 719_163; - let (cycles, rest) = (from_ce.div_euclid(146_097), from_ce.rem_euclid(146_097)); - match i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(date) => { - let y = i128::from(date.year()) + cycles * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) - } - // Unreachable: every `rest` in 0..146_097 is a valid CE day. - None => (1900, 1, 1), - } -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// Whether a format code (outside its quoted and bracketed parts) has date or time fields. -fn is_date_code(code: &str) -> bool { - format_toks_raw(code).any(|c| matches!(c.to_ascii_lowercase(), 'y' | 'd' | 'm' | 'h' | 's')) -} - -/// The characters of a format code's first section outside quotes and brackets. -fn format_toks_raw(code: &str) -> impl Iterator + '_ { - let mut quoted = false; - let mut bracket = false; - first_section(code).chars().filter(move |c| match c { - '"' => { - quoted = !quoted; - false - } - '[' if !quoted => { - bracket = true; - false - } - ']' if bracket => { - bracket = false; - false - } - _ => !quoted && !bracket, - }) -} - -/// A piece of a date format: literal text, a field (`y`, `m`, `d`, `h`, `s`, or `n` for minutes) -/// with its length, or AM/PM (`AM/PM` when true, `A/P` when false). -enum DTok { - Lit(String), - Field(char, usize), - AmPm(bool), -} - -/// The pieces of a date format's first section. `m` or `mm` right after an hour or right before -/// a seconds field is minutes. -fn date_toks(code: &str) -> Vec { - let section: Vec = first_section(code).chars().collect(); - let mut toks = Vec::new(); - let mut i = 0; - while let Some(&c) = section.get(i) { - let at = i; - i += 1; - match c { - '"' => { - let lit: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != '"').collect(); - i += lit.chars().count() + 1; - toks.push(DTok::Lit(lit)); - } - '[' => { - let inner: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != ']').collect(); - i += inner.chars().count() + 1; - toks.extend(currency(&inner).map(|s| DTok::Lit(s.to_string()))); - } - '\\' | '_' | '*' => { - let next = section.get(i).copied(); - i += 1; - match (c, next) { - ('\\', Some(n)) => toks.push(DTok::Lit(n.to_string())), - ('_', _) => toks.push(DTok::Lit(" ".to_string())), - _ => {} - } - } - 'a' | 'A' if starts_ci(§ion, at, "AM/PM") => { - i += 4; - toks.push(DTok::AmPm(true)); - } - 'a' | 'A' if starts_ci(§ion, at, "A/P") => { - i += 2; - toks.push(DTok::AmPm(false)); - } - _ if matches!(c.to_ascii_lowercase(), 'y' | 'm' | 'd' | 'h' | 's') => { - let mut n = 1; - while section.get(i).is_some_and(|q| q.eq_ignore_ascii_case(&c)) { - i += 1; - n += 1; - } - toks.push(DTok::Field(c.to_ascii_lowercase(), n)); - } - c => toks.push(DTok::Lit(c.to_string())), - } - } - mark_minutes(&mut toks); - toks -} - -/// Whether `chars` from `at` on start with `pat`, ignoring ASCII case. -fn starts_ci(chars: &[char], at: usize, pat: &str) -> bool { - pat.chars().enumerate().all(|(k, p)| chars.get(at + k).is_some_and(|c| c.eq_ignore_ascii_case(&p))) -} - -/// Turns the `m`/`mm` fields that mean minutes (after an hour, or before seconds) into `n`. -fn mark_minutes(toks: &mut [DTok]) { - let fields: Vec = toks.iter().enumerate().filter(|(_, t)| matches!(t, DTok::Field(..))).map(|(k, _)| k).collect(); - let kind = |k: Option<&usize>, toks: &[DTok]| match k.and_then(|k| toks.get(*k)) { - Some(DTok::Field(c, _)) => Some(*c), - _ => None, - }; - for (j, k) in fields.iter().enumerate() { - let after_hour = j.checked_sub(1).and_then(|p| kind(fields.get(p), toks)) == Some('h'); - let before_secs = kind(fields.get(j + 1), toks) == Some('s'); - if let Some(DTok::Field(c @ 'm', n)) = toks.get_mut(*k) - && *n <= 2 - && (after_hour || before_secs) - { - *c = 'n'; - } - } -} - -/// A moment in a date format: day, month, year, weekday (`ddd`, `dddd`), hour, minute and -/// second fields replaced (12-hour with AM/PM); quoted text and currency kept, other brackets -/// dropped. -fn date_text(t: Stamp, code: &str) -> String { - let toks = date_toks(code); - let twelve = toks.iter().any(|k| matches!(k, DTok::AmPm(_))); - let hour = t.secs / 3600; - let mut out = String::new(); - for tok in &toks { - match tok { - DTok::Lit(s) => out.push_str(s), - DTok::AmPm(long) => out.push_str(match (hour < 12, long) { - (true, true) => "AM", - (false, true) => "PM", - (true, false) => "A", - (false, false) => "P", - }), - DTok::Field(c, n) => out.push_str(&field_text(t, *c, *n, twelve)), - } - } - out -} - -/// One date or time field (`c` as in [`DTok::Field`], `n` letters long). -fn field_text(t: Stamp, c: char, n: usize, twelve: bool) -> String { - let month = MONTHS.get((t.m as usize).clamp(1, 12) - 1).copied().unwrap_or(""); - let weekday = WEEKDAYS.get(t.weekday as usize).copied().unwrap_or(""); - let pad = |v: u32| if n >= 2 { format!("{v:02}") } else { v.to_string() }; - let hour = t.secs / 3600; - match c { - 'y' if n <= 2 => format!("{:02}", t.y.rem_euclid(100)), - 'y' => format!("{:04}", t.y), - 'd' if n <= 2 => pad(t.d), - 'd' if n == 3 => weekday.get(..3).unwrap_or(weekday).to_string(), - 'd' => weekday.to_string(), - 'm' if n <= 2 => pad(t.m), - 'm' if n == 3 => month.get(..3).unwrap_or(month).to_string(), - 'm' if n == 4 => month.to_string(), - 'm' => month.get(..1).unwrap_or(month).to_string(), - 'h' if twelve => pad(if hour.is_multiple_of(12) { 12 } else { hour % 12 }), - 'h' => pad(hour), - 'n' => pad(t.secs / 60 % 60), - _ => pad(t.secs % 60), - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct Rect { - x: f32, - y: f32, - w: f32, - h: f32, -} - -impl Rect { - fn right(self) -> f32 { - self.x + self.w - } - fn bottom(self) -> f32 { - self.y + self.h - } - fn arr(self) -> [f32; 4] { - [self.x, self.y, self.w, self.h] - } -} - -/// Estimated text width, points: text is placed before it's shaped, at [`CHAR_W`] a character. -pub(super) fn text_w(s: &str, size: f32) -> f32 { - s.chars().count() as f32 * size * CHAR_W -} - -/// `s`, cut short with an ellipsis to fit `width` points by the [`text_w`] estimate. -fn clip_text(s: &str, width: f32, size: f32) -> String { - let fit = (width / (size * CHAR_W)).floor().max(0.0) as usize; - if s.chars().count() <= fit { - return s.to_string(); - } - let mut out: String = s.chars().take(fit.saturating_sub(1)).collect(); - out.push('…'); - out -} - -fn widest(items: &[String], size: f32) -> f32 { - items.iter().map(|s| text_w(s, size)).fold(0.0, f32::max) -} - -fn text(out: &mut Vec, rect: Rect, s: &str, size: f32, align: TextAlign, bold: bool) { - out.push(GraphicItem::Text { rect: rect.arr(), text: s.to_string(), size, color: TEXT, bold, align, font: None }); -} - -/// Text centred on `cx` with its top at `y`. -fn text_at(out: &mut Vec, cx: f32, y: f32, s: &str, size: f32) { - let w = text_w(s, size) + 4.0; - text(out, Rect { x: cx - w / 2.0, y, w, h: size * LINE }, s, size, TextAlign::Center, false); -} - -fn fill(out: &mut Vec, segs: Vec, color: Rgb) { - if !segs.is_empty() { - out.push(GraphicItem::Path { segs, fill: Some(color), stroke: None, stroke_width: 0.0 }); - } -} - -fn stroke(out: &mut Vec, segs: Vec, color: Rgb, width: f32) { - if !segs.is_empty() { - out.push(GraphicItem::Path { segs, fill: None, stroke: Some(color), stroke_width: width }); - } -} - -fn swatch(out: &mut Vec, x: f32, y: f32, color: Rgb) { - out.push(GraphicItem::Shape { rect: [x, y, SWATCH, SWATCH], kind: ShapeKind::Rectangle, fill: Some(color), stroke: None, stroke_width: 0.0 }); -} - -fn seg(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec { - vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y1)] -} - -fn rect_segs(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec { - vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y0), PathSeg::Line(x1, y1), PathSeg::Line(x0, y1), PathSeg::Close] -} - -/// Polyline pieces: a gap (`None`) starts a new sub-path. -fn runs(pts: impl Iterator>) -> Vec { - let mut segs = Vec::new(); - let mut open = false; - for p in pts { - match p { - Some((x, y)) => { - segs.push(if open { PathSeg::Line(x, y) } else { PathSeg::Move(x, y) }); - open = true; - } - None => open = false, - } - } - segs -} - -/// Words of `s` in lines of at most `width` points (a word wider than that keeps its own line). -fn wrap(s: &str, width: f32, size: f32) -> Vec { - let mut lines: Vec = Vec::new(); - let mut cur = String::new(); - for word in s.split_whitespace() { - let joined = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") }; - if cur.is_empty() || text_w(&joined, size) <= width { - cur = joined; - } else { - lines.push(std::mem::take(&mut cur)); - cur = word.to_string(); - } - } - if !cur.is_empty() { - lines.push(cur); - } - if lines.is_empty() { - lines.push(String::new()); - } - lines.truncate(3); - lines -} - -const SWATCH: f32 = 7.0; -/// Space between legend entries in a row, points. -const SEP: f32 = 10.0; - -/// Legend in the given side of `area`; returns the area left for the plot. -fn legend(out: &mut Vec, entries: &[(String, Rgb)], pos: Pos, area: Rect, fs: f32) -> Rect { - if entries.is_empty() { - return area; - } - // Entry texts are cut to a share of the chart, so a long name can't push the legend out. - let entries: Vec<(String, Rgb)> = entries.iter().map(|(s, c)| (clip_text(s, area.w * LEGEND_SHARE, fs), *c)).collect(); - match pos { - Pos::Right | Pos::Left => side_legend(out, &entries, pos == Pos::Left, area, fs), - Pos::Top | Pos::Bottom => row_legend(out, &entries, pos == Pos::Top, area, fs), - } -} - -/// A legend in a column at the left or right of `area`, keeping only the entries that fit above -/// its bottom; returns the area left for the plot. -fn side_legend(out: &mut Vec, entries: &[(String, Rgb)], left: bool, area: Rect, fs: f32) -> Rect { - let lh = fs * LINE; - let entries = entries.get(..((area.h / lh).floor().max(0.0) as usize).min(entries.len())).unwrap_or(&[]); - if entries.is_empty() { - return area; - } - let tw = entries.iter().map(|(s, _)| text_w(s, fs)).fold(0.0, f32::max); - let block_w = SWATCH + GAP + tw; - let block_h = lh * entries.len() as f32; - let x = if left { area.x } else { area.right() - block_w }; - let y0 = area.y + ((area.h - block_h) / 2.0).max(0.0); - for (k, (s, c)) in entries.iter().enumerate() { - let y = y0 + lh * k as f32; - swatch(out, x, y + (lh - SWATCH) / 2.0, *c); - text(out, Rect { x: x + SWATCH + GAP, y, w: tw + 2.0, h: lh }, s, fs, TextAlign::Left, false); - } - let used = block_w + PAD; - let mut area = area; - area.w = (area.w - used).max(1.0); - if left { - area.x += used; - } - area -} - -/// A legend in centred rows the width of `area` above or below it (at most -/// [`MAX_LEGEND_ROWS`], and half its height); returns the area left for the plot. -fn row_legend(out: &mut Vec, entries: &[(String, Rgb)], top: bool, area: Rect, fs: f32) -> Rect { - let lh = fs * LINE; - let max_rows = MAX_LEGEND_ROWS.min((area.h / 2.0 / lh).floor().max(1.0) as usize); - let rows = legend_rows(entries, area.w, fs, max_rows); - let y0 = if top { area.y } else { area.bottom() - lh * rows.len() as f32 }; - for (r, row) in rows.iter().enumerate() { - let total = row.iter().map(|(_, _, w)| SWATCH + GAP + w).sum::() + SEP * row.len().saturating_sub(1) as f32; - let mut x = area.x + ((area.w - total) / 2.0).max(0.0); - let y = y0 + lh * r as f32; - for (s, c, w) in row { - swatch(out, x, y + (lh - SWATCH) / 2.0, *c); - text(out, Rect { x: x + SWATCH + GAP, y, w: w + 2.0, h: lh }, s, fs, TextAlign::Left, false); - x += SWATCH + GAP + w + SEP; - } - } - let used = lh * rows.len() as f32 + PAD; - let mut area = area; - area.h = (area.h - used).max(1.0); - if top { - area.y += used; - } - area -} - -/// Legend entries (text, colour, text width) packed into at most `max_rows` rows of `width` -/// points; the entries past the last row are left out. -fn legend_rows(entries: &[(String, Rgb)], width: f32, fs: f32, max_rows: usize) -> Vec> { - let mut rows: Vec> = Vec::new(); - let mut row_w = 0.0; - for (s, c) in entries { - let w = text_w(s, fs); - let entry_w = SWATCH + GAP + w; - let fits = !rows.is_empty() && row_w + SEP + entry_w <= width; - if fits { - row_w += SEP + entry_w; - } else if rows.len() < max_rows { - rows.push(Vec::new()); - row_w = entry_w; - } else { - break; - } - if let Some(row) = rows.last_mut() { - row.push((s.clone(), *c, w)); - } - } - rows -} - -/// The plot rectangle and the scales that map values and categories onto it. -struct Frame { - plot: Rect, - horiz: bool, - n: usize, - vs: Scale, - xs: Option, - mid: bool, -} - -impl Frame { - /// Width of one category band. - fn band(&self) -> f32 { - let len = if self.horiz { self.plot.h } else { self.plot.w }; - len / self.n.max(1) as f32 - } - - /// Start of category `i`'s band: its left edge, or for bars its top edge (category 0 at the - /// bottom). - fn band_start(&self, i: usize) -> f32 { - let k = i as f32; - if self.horiz { self.plot.bottom() - (k + 1.0) * self.band() } else { self.plot.x + k * self.band() } - } - - /// A value's position along the value direction: y for columns, x for bars. - fn val(&self, v: f64) -> f32 { - let t = ((v - self.vs.lo) / (self.vs.hi - self.vs.lo)).clamp(0.0, 1.0) as f32; - if self.horiz { self.plot.x + t * self.plot.w } else { self.plot.bottom() - t * self.plot.h } - } - - /// x of category `i` for line and area points. - fn cat_x(&self, i: usize) -> f32 { - if !self.mid { - return self.plot.x + (i as f32 + 0.5) * self.band(); - } - if self.n <= 1 { self.plot.x + self.plot.w / 2.0 } else { self.plot.x + self.plot.w * i as f32 / (self.n - 1) as f32 } - } - - /// x of a scatter point. - fn scat_x(&self, x: f64) -> f32 { - match self.xs { - Some(s) => self.plot.x + ((x - s.lo) / (s.hi - s.lo)).clamp(0.0, 1.0) as f32 * self.plot.w, - None => self.plot.x, - } - } -} - -/// The value scale and, for scatter charts, the x scale, with their ticks and tick texts. -struct Axes { - vs: Scale, - vticks: Vec<(f64, String)>, - xs: Option, - xticks: Vec<(f64, String)>, -} - -impl Axes { - fn of(c: &Chart) -> Axes { - let (dlo, dhi) = extent(c); - let (vmin, vmax) = if c.grouping == Grouping::Percent { (Some(0.0), Some(1.0)) } else { (c.val.min, c.val.max) }; - // Lines and scatters don't need zero for a narrow range (1000..1050 stays on its own scale). - let zero = !matches!(c.kind, Kind::Line | Kind::Scatter) || !narrow(dlo, dhi); - let vs = nice_scale(dlo, dhi, zero, vmin, vmax); - let vticks = vs.ticks().into_iter().map(|t| (t, format_value(t, &c.val_fmt, vs.step))).collect(); - let xs = (c.kind == Kind::Scatter).then(|| { - let (xl, xh) = x_extent(c); - nice_scale(xl, xh, false, c.cat.min, c.cat.max) - }); - // Scatter x values show in the axis's own format, else their cache's (dates included). - let xfmt = c.cat.fmt.clone().or_else(|| c.series.iter().find_map(|s| s.x_fmt.clone())).unwrap_or_else(|| "General".to_string()); - let xticks = xs.map(|s| s.ticks().into_iter().map(|t| (t, category_text(t, &xfmt, s.step, c.date1904))).collect()).unwrap_or_default(); - Axes { vs, vticks, xs, xticks } - } -} - -/// The room around the plot for axis labels, and the category labels as they'll be drawn. -struct Margins { - left: f32, - top: f32, - bottom: f32, - /// Category labels in lines (none for scatter charts or a deleted category axis). - wrapped: Vec>, - /// Every `skip`-th category label is shown under a column chart. - skip: usize, -} - -/// The margins the axis labels need around the plot in `area`. Category labels under a column -/// chart wrap to the band; labels that still don't fit show every k-th category. -fn axis_margins(c: &Chart, ax: &Axes, area: Rect) -> Margins { - let fs = c.text_size; - let lh = fs * LINE; - let scatter = c.kind == Kind::Scatter; - let (cat_on, val_on) = (!c.cat.deleted, !c.val.deleted); - let left = if c.horizontal { - if cat_on { widest(&c.cats, fs).min(area.w * 0.4) + GAP } else { 0.0 } - } else if val_on { - ax.vticks.iter().map(|(_, s)| text_w(s, fs)).fold(0.0, f32::max) + GAP - } else { - 0.0 - }; - let band_w = (area.w - left).max(1.0) / c.n as f32; - let wrapped: Vec> = if cat_on && !scatter { - let width = if c.horizontal { left - GAP } else { band_w }; - c.cats.iter().map(|s| wrap(s, width, fs)).collect() - } else { - Vec::new() - }; - let widest_line = wrapped.iter().flatten().map(|s| text_w(s, fs)).fold(0.0, f32::max); - let skip = ((widest_line / band_w).ceil() as usize).max(1); - let label_lines = wrapped.iter().map(Vec::len).max().unwrap_or(1) as f32; - let bottom = if cat_on && !c.horizontal { - if scatter { lh + GAP } else { label_lines * lh + GAP } - } else if c.horizontal && val_on { - lh + GAP - } else { - 0.0 - }; - let top = if !c.horizontal && val_on { lh / 2.0 } else { 0.0 }; - Margins { left, top, bottom, wrapped, skip } -} - -/// A bar, column, line, area or scatter chart in `area`: gridlines, series, axes, then labels. -fn cartesian(out: &mut Vec, c: &Chart, area: Rect) { - if c.n == 0 { - return; - } - let ax = Axes::of(c); - let m = axis_margins(c, &ax, area); - let plot = Rect { x: area.x + m.left, y: area.y + m.top, w: area.w - m.left, h: area.h - m.top - m.bottom }; - if plot.w < 1.0 || plot.h < 1.0 { - return; - } - let g = Frame { plot, horiz: c.horizontal, n: c.n, vs: ax.vs, xs: ax.xs, mid: c.val.mid }; - if c.val.grid { - draw_grid(out, &g, &ax.vticks); - } - let mut labels = Vec::new(); - match c.kind { - Kind::Bar => bars(out, c, &g, &mut labels), - Kind::Line => lines(out, c, &g, &mut labels), - Kind::Area => areas(out, c, &g, &mut labels), - Kind::Scatter => draw_scatter(out, c, &g, &mut labels), - Kind::Pie | Kind::Doughnut | Kind::Radar => {} - } - draw_axes(out, c, &g); - draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar); - if !c.val.deleted { - draw_value_labels(out, c, &g, &ax.vticks, Rect { w: m.left, ..area }); - } - if !c.cat.deleted { - draw_category_labels(out, c, &g, &ax.xticks, &m, area); - } -} - -/// Major gridlines at the value ticks. -fn draw_grid(out: &mut Vec, g: &Frame, ticks: &[(f64, String)]) { - let plot = g.plot; - for (t, _) in ticks { - let p = g.val(*t); - let segs = if g.horiz { seg(p, plot.y, p, plot.bottom()) } else { seg(plot.x, p, plot.right(), p) }; - stroke(out, segs, GRID, 0.75); - } -} - -/// The category axis line, the plot area's outline and the value axis line, as the file shows them. -fn draw_axes(out: &mut Vec, c: &Chart, g: &Frame) { - let plot = g.plot; - if !c.cat.deleted && c.cat.line { - let segs = if g.horiz { seg(plot.x, plot.y, plot.x, plot.bottom()) } else { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) }; - stroke(out, segs, AXIS, 0.75); - } - if let Some(color) = c.plot_border { - stroke(out, rect_segs(plot.x, plot.y, plot.right(), plot.bottom()), color, 0.75); - } - if !c.val.deleted && c.val.line { - let segs = if g.horiz { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) } else { seg(plot.x, plot.y, plot.x, plot.bottom()) }; - stroke(out, segs, AXIS, 0.75); - } -} - -/// Value tick labels: under the plot for bars, else right-aligned in `margin` (the area's left -/// margin). -fn draw_value_labels(out: &mut Vec, c: &Chart, g: &Frame, ticks: &[(f64, String)], margin: Rect) { - let (fs, lh) = (c.text_size, c.text_size * LINE); - for (t, s) in ticks { - let p = g.val(*t); - if g.horiz { - text_at(out, p, g.plot.bottom() + GAP / 2.0, s, fs); - } else { - text(out, Rect { x: margin.x, y: p - lh / 2.0, w: margin.w - GAP, h: lh }, s, fs, TextAlign::Right, false); - } - } -} - -/// Category labels: scatter x ticks under the plot, bar labels in the left margin, column -/// labels under their bands (every `m.skip`-th). -fn draw_category_labels(out: &mut Vec, c: &Chart, g: &Frame, xticks: &[(f64, String)], m: &Margins, area: Rect) { - let (fs, lh) = (c.text_size, c.text_size * LINE); - let plot = g.plot; - if c.kind == Kind::Scatter { - for (t, s) in xticks { - text_at(out, g.scat_x(*t), plot.bottom() + GAP / 2.0, s, fs); - } - } else if g.horiz { - for (i, lines) in m.wrapped.iter().enumerate() { - let top = g.band_start(i) + g.band() / 2.0 - lines.len() as f32 * lh / 2.0; - for (k, s) in lines.iter().enumerate() { - text(out, Rect { x: area.x, y: top + k as f32 * lh, w: m.left - GAP, h: lh }, s, fs, TextAlign::Right, false); - } - } - } else { - let band = g.band(); - for (i, lines) in m.wrapped.iter().enumerate().filter(|(i, _)| i % m.skip == 0) { - for (k, s) in lines.iter().enumerate() { - let y = plot.bottom() + GAP / 2.0 + k as f32 * lh; - text(out, Rect { x: g.cat_x(i) - band / 2.0, y, w: band, h: lh }, s, fs, TextAlign::Center, false); - } - } - } -} - -/// Whether data `lo..hi` is a narrow band away from zero: its span is under a sixth of the end -/// farther from zero (the mirror image for negative data). -fn narrow(lo: f64, hi: f64) -> bool { - if lo > 0.0 { - (hi - lo) / hi < 1.0 / 6.0 - } else if hi < 0.0 { - (hi - lo) / lo.abs() < 1.0 / 6.0 - } else { - false - } -} - -/// Lowest and highest value of the plot; stacked series add up per category. -fn extent(c: &Chart) -> (f64, f64) { - let mut pts: Vec = Vec::new(); - if c.stacked() { - for i in 0..c.n { - let (mut p, mut q) = (0.0, 0.0); - for s in &c.series { - match s.values.get(i).copied().flatten() { - Some(v) if v >= 0.0 => p += v, - Some(v) => q += v, - None => {} - } - } - pts.push(p); - pts.push(q); - } - } else { - pts.extend(c.series.iter().flat_map(|s| s.values.iter().flatten().copied())); - } - let lo = pts.iter().copied().fold(f64::INFINITY, f64::min); - let hi = pts.iter().copied().fold(f64::NEG_INFINITY, f64::max); - if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 0.0) } -} - -/// Range of scatter x values (index + 1 where missing). -fn x_extent(c: &Chart) -> (f64, f64) { - let xs: Vec = c.series.iter().flat_map(|s| s.xs.iter().flatten().copied()).collect(); - let lo = xs.iter().copied().fold(f64::INFINITY, f64::min); - let hi = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max); - if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 1.0) } -} - -fn bars(out: &mut Vec, c: &Chart, g: &Frame, labels: &mut Vec) { - let stacked = c.stacked(); - let slots = if stacked { 1 } else { c.series.len().max(1) }; - let ov = if stacked { 0.0 } else { c.overlap }; - let ns = slots as f32; - let band = g.band(); - let thick = band / (ns - (ns - 1.0) * ov + c.gap); - let group = thick * (ns - (ns - 1.0) * ov); - let mut pos = vec![0.0f64; g.n]; - let mut neg = vec![0.0f64; g.n]; - for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) { - let slot = if stacked { 0 } else { j }; - let slot = (if g.horiz { slots.saturating_sub(1 + slot) } else { slot }) as f32; - for i in 0..g.n { - let Some(v) = s.values.get(i).copied().flatten() else { continue }; - let (v0, v1) = if !stacked { - (0.0, v) - } else if v >= 0.0 { - let Some(p) = pos.get_mut(i) else { continue }; - let b = *p; - *p += v; - (b, *p) - } else { - let Some(q) = neg.get_mut(i) else { continue }; - let b = *q; - *q += v; - (b, *q) - }; - let a = g.band_start(i) + (band - group) / 2.0 + slot * thick * (1.0 - ov); - let (p0, p1) = (g.val(v0), g.val(v1)); - let (p0, p1) = (p0.min(p1), p0.max(p1)); - let segs = if g.horiz { rect_segs(p0, a, p1, a + thick) } else { rect_segs(a, p0, a + thick, p1) }; - fill(out, segs, c.point_colour(j, i)); - if let Some(text) = label_of(s, i) { - let mid = a + thick / 2.0; - let (x, y) = match (g.horiz, stacked) { - (true, true) => ((p0 + p1) / 2.0, mid), - (false, true) => (mid, (p0 + p1) / 2.0), - // Beyond the bar's end: right of a positive bar, above a positive column. - (true, false) => (if v >= 0.0 { p1 } else { p0 }, mid), - (false, false) => (mid, if v >= 0.0 { p0 } else { p1 }), - }; - labels.push(DataLabel { x, y, text, inside: stacked }); - } - } - } -} - -/// Whether a series has any point to draw. -fn has_values(s: &Series) -> bool { - s.values.iter().any(Option::is_some) -} - -/// Line series; stacked ones are drawn at the running total of the series up to them. -fn lines(out: &mut Vec, c: &Chart, g: &Frame, labels: &mut Vec) { - let mut total = vec![0.0f64; g.n]; - for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) { - let pts: Vec> = (0..g.n) - .map(|i| { - let v = s.values.get(i).copied().flatten()?; - let y = match total.get_mut(i) { - Some(t) if c.stacked() => { - *t += v; - *t - } - _ => v, - }; - Some((g.cat_x(i), g.val(y))) - }) - .collect(); - for (i, p) in pts.iter().enumerate() { - if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) { - labels.push(DataLabel { x: *x, y: *y, text, inside: false }); - } - } - stroke(out, runs(pts.into_iter()), c.series_colour(j), s.line_w); - } -} - -fn areas(out: &mut Vec, c: &Chart, g: &Frame, labels: &mut Vec) { - let mut base = vec![0.0f64; g.n]; - for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) { - let vals = (0..g.n).map(|i| s.values.get(i).copied().flatten().unwrap_or(0.0)); - let top: Vec = base.iter().zip(vals).map(|(b, v)| b + v).collect(); - let mut segs = Vec::with_capacity(2 * g.n + 1); - for (i, t) in top.iter().enumerate() { - let (x, y) = (g.cat_x(i), g.val(*t)); - segs.push(if i == 0 { PathSeg::Move(x, y) } else { PathSeg::Line(x, y) }); - } - for (i, b) in base.iter().enumerate().rev() { - segs.push(PathSeg::Line(g.cat_x(i), g.val(*b))); - } - segs.push(PathSeg::Close); - fill(out, segs, c.series_colour(j)); - for (i, (t, b)) in top.iter().zip(&base).enumerate() { - if let Some(text) = label_of(s, i) { - labels.push(DataLabel { x: g.cat_x(i), y: g.val((t + b) / 2.0), text, inside: true }); - } - } - if c.stacked() { - base = top; - } - } -} - -/// Scatter series as lines, or as small markers when the series has no line. -fn draw_scatter(out: &mut Vec, c: &Chart, g: &Frame, labels: &mut Vec) { - const MARKER: f32 = 6.0; - for (j, s) in c.series.iter().enumerate() { - let pts: Vec> = - s.xs.iter() - .zip(&s.values) - .map(|(x, y)| match (x, y) { - (Some(x), Some(y)) => Some((g.scat_x(*x), g.val(*y))), - _ => None, - }) - .collect(); - for (i, p) in pts.iter().enumerate() { - if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) { - labels.push(DataLabel { x: *x, y: *y - MARKER / 2.0, text, inside: false }); - } - } - if s.line { - stroke(out, runs(pts.iter().copied()), c.series_colour(j), s.line_w); - continue; - } - for (x, y) in pts.iter().flatten() { - let rect = [x - MARKER / 2.0, y - MARKER / 2.0, MARKER, MARKER]; - out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(c.series_colour(j)), stroke: None, stroke_width: 0.0 }); - } - } -} - -fn pie(out: &mut Vec, c: &Chart, area: Rect) { - let Some(s) = c.series.first() else { return }; - let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); - let total: f64 = vals.iter().sum(); - let r = (area.w.min(area.h) / 2.0 - 2.0) as f64; - if !(total > 0.0 && total.is_finite() && r >= 1.0) { - return; - } - let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64); - let inner = if c.kind == Kind::Doughnut { r * c.hole as f64 } else { 0.0 }; - let mut a = c.first_angle.to_radians(); - let mut labels = Vec::new(); - for (i, v) in vals.iter().enumerate() { - if *v <= 0.0 { - continue; - } - let b = a + v / total * TAU; - let mut segs = Vec::new(); - if inner > 0.0 { - segs.push(pt(polar(cx, cy, r, a))); - arc(&mut segs, (cx, cy, r), a, b); - segs.push(line_to(polar(cx, cy, inner, b))); - arc(&mut segs, (cx, cy, inner), b, a); - } else { - segs.push(pt((cx, cy))); - segs.push(line_to(polar(cx, cy, r, a))); - arc(&mut segs, (cx, cy, r), a, b); - } - segs.push(PathSeg::Close); - fill(out, segs, c.point_colour(0, i)); - if let Some(text) = label_of(s, i) { - // Halfway across the ring (pie: two thirds out from the centre). - let at = if inner > 0.0 { (r + inner) / 2.0 } else { r * 0.65 }; - let (x, y) = polar(cx, cy, at, (a + b) / 2.0); - labels.push(DataLabel { x: x as f32, y: y as f32, text, inside: true }); - } - a = b; - } - draw_data_labels(out, &labels, c.text_size, false); -} - -/// A radar chart in `area`: category spokes from the centre, concentric grid polygons at the value -/// ticks, one polygon per series (filled, or outlined with markers) and the category labels at the -/// spoke ends. The lowest tick is the centre. -fn radar(out: &mut Vec, c: &Chart, area: Rect) { - const MARKER: f32 = 5.0; - if c.n == 0 { - return; - } - let fs = c.text_size; - let lh = fs * LINE; - let labelled = c.cats.iter().any(|s| !s.is_empty()); - let side = if labelled { widest(&c.cats, fs).min(area.w * 0.25) + GAP + 2.0 } else { 0.0 }; - let vert = if labelled { lh + GAP } else { 0.0 }; - let r = ((area.w / 2.0 - side).min(area.h / 2.0 - vert) - 2.0) as f64; - if r.is_nan() || r < 8.0 { - return; - } - let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64); - let vs = Axes::of(c).vs; - let span = vs.hi - vs.lo; - let angle = |i: usize| i as f64 / c.n as f64 * TAU; - let at = |v: f64, i: usize| { - let t = if span > 0.0 { ((v - vs.lo) / span).clamp(0.0, 1.0) } else { 0.0 }; - polar(cx, cy, r * t, angle(i)) - }; - let ticks = vs.ticks(); - if c.val.grid { - for t in ticks.iter().skip(1) { - let mut segs: Vec = (0..c.n).map(|i| if i == 0 { pt(at(*t, i)) } else { line_to(at(*t, i)) }).collect(); - segs.push(PathSeg::Close); - stroke(out, segs, GRID, 0.75); - } - } - for i in 0..c.n { - let end = polar(cx, cy, r, angle(i)); - stroke(out, vec![pt((cx, cy)), line_to(end)], GRID, 0.75); - } - let mut labels = Vec::new(); - for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) { - let col = c.series_colour(j); - // A missing value counts as the centre, as Word draws it. - let ring: Vec<(f64, f64)> = (0..c.n).map(|i| at(s.values.get(i).copied().flatten().unwrap_or(vs.lo), i)).collect(); - let mut segs: Vec = ring.iter().enumerate().map(|(i, p)| if i == 0 { pt(*p) } else { line_to(*p) }).collect(); - segs.push(PathSeg::Close); - if c.radar_fill { - fill(out, segs.clone(), col); - } - stroke(out, segs, col, s.line_w); - for (i, p) in ring.iter().enumerate() { - if c.radar_marks && s.values.get(i).copied().flatten().is_some() { - let rect = [p.0 as f32 - MARKER / 2.0, p.1 as f32 - MARKER / 2.0, MARKER, MARKER]; - out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(col), stroke: None, stroke_width: 0.0 }); - } - if let Some(text) = label_of(s, i) { - labels.push(DataLabel { x: p.0 as f32, y: p.1 as f32, text, inside: false }); - } - } - } - draw_data_labels(out, &labels, fs, false); - if !c.val.deleted { - for t in &ticks { - let y = at(*t, 0).1 as f32; - let s = format_value(*t, &c.val_fmt, vs.step); - let w = text_w(&s, fs) + 4.0; - text(out, Rect { x: cx as f32 - GAP / 2.0 - w, y: y - lh / 2.0, w, h: lh }, &s, fs, TextAlign::Right, false); - } - } - if labelled { - for (i, s) in c.cats.iter().enumerate().filter(|(_, s)| !s.is_empty()) { - let a = angle(i); - let (x, y) = polar(cx, cy, r + GAP as f64, a); - let (x, y) = (x as f32, y as f32); - let w = side - GAP; - let txt = clip_text(s, w, fs); - let (sx, sy) = (a.sin(), a.cos()); - let (rect, align) = if sx > 0.2 { - (Rect { x, y: y - lh / 2.0, w, h: lh }, TextAlign::Left) - } else if sx < -0.2 { - (Rect { x: x - w, y: y - lh / 2.0, w, h: lh }, TextAlign::Right) - } else { - let cw = (text_w(&txt, fs) + 4.0).max(1.0); - (Rect { x: x - cw / 2.0, y: if sy > 0.0 { y - lh } else { y }, w: cw, h: lh }, TextAlign::Center) - }; - text(out, rect, &txt, fs, align, false); - } - } -} - -/// The data label of point `i` of series `s`, when it shows one: the value as the file has it, -/// in the series' number format. -fn label_of(s: &Series, i: usize) -> Option { - if s.labels != Some(true) { - return None; - } - let v = s.raw.get(i).copied().flatten()?; - let fmt = s.fmt.as_deref().filter(|f| !f.eq_ignore_ascii_case("General")); - Some(match fmt { - Some(f) => format_value(v, f, 1.0), - None => general_value(v), - }) -} - -/// A value in the `General` format: whole numbers without decimals, others with up to six. -fn general_value(v: f64) -> String { - if v.fract() == 0.0 && v.abs() < 1e15 { - return format!("{v:.0}"); - } - let s = format!("{v:.6}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".to_string() } else { s.to_string() } -} - -/// Data labels over the drawn chart. Beside labels sit above their point, or right of it for -/// horizontal bars (`beside_right`). -fn draw_data_labels(out: &mut Vec, labels: &[DataLabel], fs: f32, beside_right: bool) { - let lh = fs * LINE; - for l in labels { - let w = text_w(&l.text, fs) + 4.0; - let (rect, align) = if l.inside { - (Rect { x: l.x - w / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Center) - } else if beside_right { - (Rect { x: l.x + GAP / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Left) - } else { - (Rect { x: l.x - w / 2.0, y: l.y - lh, w, h: lh }, TextAlign::Center) - }; - let color = if l.inside { Rgb::WHITE } else { TEXT }; - out.push(GraphicItem::Text { rect: rect.arr(), text: l.text.clone(), size: fs, color, bold: false, align, font: None }); - } -} - -/// A point at clockwise angle `t` (radians from 12 o'clock) on a circle of radius `r`. -fn polar(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) { - (cx + r * t.sin(), cy - r * t.cos()) -} - -fn pt(p: (f64, f64)) -> PathSeg { - PathSeg::Move(p.0 as f32, p.1 as f32) -} - -fn line_to(p: (f64, f64)) -> PathSeg { - PathSeg::Line(p.0 as f32, p.1 as f32) -} - -/// Cubic segments along a circle from angle `a` to `b` (the current point is at `a`). Each piece -/// spans at most a quarter turn. -fn arc(segs: &mut Vec, (cx, cy, r): (f64, f64, f64), a: f64, b: f64) { - let n = (((b - a).abs() / FRAC_PI_2).ceil() as usize).clamp(1, 16); - let d = (b - a) / n as f64; - let k = 4.0 / 3.0 * (d / 4.0).tan(); - for j in 0..n { - let t0 = a + d * j as f64; - let t1 = t0 + d; - let p0 = polar(cx, cy, r, t0); - let p1 = polar(cx, cy, r, t1); - let c1 = (p0.0 + k * r * t0.cos(), p0.1 + k * r * t0.sin()); - let c2 = (p1.0 - k * r * t1.cos(), p1.1 - k * r * t1.sin()); - segs.push(PathSeg::Cubic(c1.0 as f32, c1.1 as f32, c2.0 as f32, c2.1 as f32, p1.0 as f32, p1.1 as f32)); - } -} - #[cfg(test)] mod tests; diff --git a/crates/docx/src/read/chart/canvas.rs b/crates/docx/src/read/chart/canvas.rs new file mode 100644 index 000000000..f83ab3db4 --- /dev/null +++ b/crates/docx/src/read/chart/canvas.rs @@ -0,0 +1,210 @@ +use super::model::Pos; +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Rect { + pub(super) x: f32, + pub(super) y: f32, + pub(super) w: f32, + pub(super) h: f32, +} + +impl Rect { + pub(super) fn right(self) -> f32 { + self.x + self.w + } + pub(super) fn bottom(self) -> f32 { + self.y + self.h + } + pub(super) fn arr(self) -> [f32; 4] { + [self.x, self.y, self.w, self.h] + } +} + +/// Estimated text width, points: text is placed before it's shaped, at [`CHAR_W`] a character. +pub(in super::super) fn text_w(s: &str, size: f32) -> f32 { + s.chars().count() as f32 * size * CHAR_W +} + +/// `s`, cut short with an ellipsis to fit `width` points by the [`text_w`] estimate. +pub(super) fn clip_text(s: &str, width: f32, size: f32) -> String { + let fit = (width / (size * CHAR_W)).floor().max(0.0) as usize; + if s.chars().count() <= fit { + return s.to_string(); + } + let mut out: String = s.chars().take(fit.saturating_sub(1)).collect(); + out.push('…'); + out +} + +pub(super) fn widest(items: &[String], size: f32) -> f32 { + items.iter().map(|s| text_w(s, size)).fold(0.0, f32::max) +} + +pub(super) fn text(out: &mut Vec, rect: Rect, s: &str, size: f32, align: TextAlign, bold: bool) { + out.push(GraphicItem::Text { rect: rect.arr(), text: s.to_string(), size, color: TEXT, bold, align, font: None }); +} + +/// Text centred on `cx` with its top at `y`. +pub(super) fn text_at(out: &mut Vec, cx: f32, y: f32, s: &str, size: f32) { + let w = text_w(s, size) + 4.0; + text(out, Rect { x: cx - w / 2.0, y, w, h: size * LINE }, s, size, TextAlign::Center, false); +} + +pub(super) fn fill(out: &mut Vec, segs: Vec, color: Rgb) { + if !segs.is_empty() { + out.push(GraphicItem::Path { segs, fill: Some(color), stroke: None, stroke_width: 0.0 }); + } +} + +pub(super) fn stroke(out: &mut Vec, segs: Vec, color: Rgb, width: f32) { + if !segs.is_empty() { + out.push(GraphicItem::Path { segs, fill: None, stroke: Some(color), stroke_width: width }); + } +} + +fn swatch(out: &mut Vec, x: f32, y: f32, color: Rgb) { + out.push(GraphicItem::Shape { rect: [x, y, SWATCH, SWATCH], kind: ShapeKind::Rectangle, fill: Some(color), stroke: None, stroke_width: 0.0 }); +} + +pub(super) fn seg(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec { + vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y1)] +} + +pub(super) fn rect_segs(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec { + vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y0), PathSeg::Line(x1, y1), PathSeg::Line(x0, y1), PathSeg::Close] +} + +/// Polyline pieces: a gap (`None`) starts a new sub-path. +pub(super) fn runs(pts: impl Iterator>) -> Vec { + let mut segs = Vec::new(); + let mut open = false; + for p in pts { + match p { + Some((x, y)) => { + segs.push(if open { PathSeg::Line(x, y) } else { PathSeg::Move(x, y) }); + open = true; + } + None => open = false, + } + } + segs +} + +/// Words of `s` in lines of at most `width` points (a word wider than that keeps its own line). +pub(super) fn wrap(s: &str, width: f32, size: f32) -> Vec { + let mut lines: Vec = Vec::new(); + let mut cur = String::new(); + for word in s.split_whitespace() { + let joined = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") }; + if cur.is_empty() || text_w(&joined, size) <= width { + cur = joined; + } else { + lines.push(std::mem::take(&mut cur)); + cur = word.to_string(); + } + } + if !cur.is_empty() { + lines.push(cur); + } + if lines.is_empty() { + lines.push(String::new()); + } + lines.truncate(3); + lines +} + +pub(super) const SWATCH: f32 = 7.0; + +/// Space between legend entries in a row, points. +const SEP: f32 = 10.0; + +/// Legend in the given side of `area`; returns the area left for the plot. +pub(super) fn legend(out: &mut Vec, entries: &[(String, Rgb)], pos: Pos, area: Rect, fs: f32) -> Rect { + if entries.is_empty() { + return area; + } + // Entry texts are cut to a share of the chart, so a long name can't push the legend out. + let entries: Vec<(String, Rgb)> = entries.iter().map(|(s, c)| (clip_text(s, area.w * LEGEND_SHARE, fs), *c)).collect(); + match pos { + Pos::Right | Pos::Left => side_legend(out, &entries, pos == Pos::Left, area, fs), + Pos::Top | Pos::Bottom => row_legend(out, &entries, pos == Pos::Top, area, fs), + } +} + +/// A legend in a column at the left or right of `area`, keeping only the entries that fit above +/// its bottom; returns the area left for the plot. +fn side_legend(out: &mut Vec, entries: &[(String, Rgb)], left: bool, area: Rect, fs: f32) -> Rect { + let lh = fs * LINE; + let entries = entries.get(..((area.h / lh).floor().max(0.0) as usize).min(entries.len())).unwrap_or(&[]); + if entries.is_empty() { + return area; + } + let tw = entries.iter().map(|(s, _)| text_w(s, fs)).fold(0.0, f32::max); + let block_w = SWATCH + GAP + tw; + let block_h = lh * entries.len() as f32; + let x = if left { area.x } else { area.right() - block_w }; + let y0 = area.y + ((area.h - block_h) / 2.0).max(0.0); + for (k, (s, c)) in entries.iter().enumerate() { + let y = y0 + lh * k as f32; + swatch(out, x, y + (lh - SWATCH) / 2.0, *c); + text(out, Rect { x: x + SWATCH + GAP, y, w: tw + 2.0, h: lh }, s, fs, TextAlign::Left, false); + } + let used = block_w + PAD; + let mut area = area; + area.w = (area.w - used).max(1.0); + if left { + area.x += used; + } + area +} + +/// A legend in centred rows the width of `area` above or below it (at most +/// [`MAX_LEGEND_ROWS`], and half its height); returns the area left for the plot. +fn row_legend(out: &mut Vec, entries: &[(String, Rgb)], top: bool, area: Rect, fs: f32) -> Rect { + let lh = fs * LINE; + let max_rows = MAX_LEGEND_ROWS.min((area.h / 2.0 / lh).floor().max(1.0) as usize); + let rows = legend_rows(entries, area.w, fs, max_rows); + let y0 = if top { area.y } else { area.bottom() - lh * rows.len() as f32 }; + for (r, row) in rows.iter().enumerate() { + let total = row.iter().map(|(_, _, w)| SWATCH + GAP + w).sum::() + SEP * row.len().saturating_sub(1) as f32; + let mut x = area.x + ((area.w - total) / 2.0).max(0.0); + let y = y0 + lh * r as f32; + for (s, c, w) in row { + swatch(out, x, y + (lh - SWATCH) / 2.0, *c); + text(out, Rect { x: x + SWATCH + GAP, y, w: w + 2.0, h: lh }, s, fs, TextAlign::Left, false); + x += SWATCH + GAP + w + SEP; + } + } + let used = lh * rows.len() as f32 + PAD; + let mut area = area; + area.h = (area.h - used).max(1.0); + if top { + area.y += used; + } + area +} + +/// Legend entries (text, colour, text width) packed into at most `max_rows` rows of `width` +/// points; the entries past the last row are left out. +fn legend_rows(entries: &[(String, Rgb)], width: f32, fs: f32, max_rows: usize) -> Vec> { + let mut rows: Vec> = Vec::new(); + let mut row_w = 0.0; + for (s, c) in entries { + let w = text_w(s, fs); + let entry_w = SWATCH + GAP + w; + let fits = !rows.is_empty() && row_w + SEP + entry_w <= width; + if fits { + row_w += SEP + entry_w; + } else if rows.len() < max_rows { + rows.push(Vec::new()); + row_w = entry_w; + } else { + break; + } + if let Some(row) = rows.last_mut() { + row.push((s.clone(), *c, w)); + } + } + rows +} diff --git a/crates/docx/src/read/chart/format.rs b/crates/docx/src/read/chart/format.rs new file mode 100644 index 000000000..5b3a2f089 --- /dev/null +++ b/crates/docx/src/read/chart/format.rs @@ -0,0 +1,370 @@ +/// A piece of a format section: literal text, a number character (`#`, `0`, `,`, `.`), or the +/// exponent of a scientific format (`E+` when true, `E-` when false). +enum Tok { + Lit(String), + Num(char), + Exp(bool), +} + +/// The sections of a format code (positive; negative; zero; text), split outside quotes. +fn sections(code: &str) -> Vec<&str> { + let mut out = Vec::new(); + let (mut start, mut quoted, mut escaped) = (0, false, false); + for (i, c) in code.char_indices() { + match c { + _ if escaped => escaped = false, + '\\' if !quoted => escaped = true, + '"' => quoted = !quoted, + ';' if !quoted => { + out.push(code.get(start..i).unwrap_or("")); + start = i + 1; + } + _ => {} + } + } + out.push(code.get(start..).unwrap_or("")); + out +} + +/// The first section of a format code. +fn first_section(code: &str) -> &str { + sections(code).first().copied().unwrap_or("") +} + +/// The pieces of one format section. Quoted text, escapes and `[$€-407]` currency symbols are +/// literal; other brackets (`[Red]`) are dropped; `_x` (room for `x`) is a space; `*x` is dropped. +fn format_toks(section: &str) -> Vec { + let mut toks = Vec::new(); + let mut chars = section.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' => toks.push(Tok::Lit(chars.by_ref().take_while(|q| *q != '"').collect())), + '[' => { + let inner: String = chars.by_ref().take_while(|q| *q != ']').collect(); + if let Some(sym) = currency(&inner) { + toks.push(Tok::Lit(sym.to_string())); + } + } + '\\' => toks.extend(chars.next().map(|n| Tok::Lit(n.to_string()))), + '_' => { + chars.next(); + toks.push(Tok::Lit(" ".to_string())); + } + '*' => { + chars.next(); + } + 'E' | 'e' if matches!(chars.peek(), Some('+' | '-')) => toks.push(Tok::Exp(chars.next() == Some('+'))), + '#' | '0' | ',' | '.' => toks.push(Tok::Num(c)), + c => toks.push(Tok::Lit(c.to_string())), + } + } + toks +} + +/// The symbol of a `$€-407` bracket content (`[$€-407]`), if it is one. +fn currency(inner: &str) -> Option<&str> { + inner.strip_prefix('$').map(|rest| rest.split('-').next().unwrap_or("")) +} + +/// Tick text for `v` in a number format code (`#,##0`, `0.0`, `0%`, `0.00E+00`, and literal text +/// such as `"€"0.00` or `#,##0.00 [$€-407]`). A negative number takes the second section when +/// there is one, which shows it without a minus (`#,##0;(#,##0)`). `General` shows as many +/// decimals as the step needs. +pub(super) fn format_value(v: f64, code: &str, step: f64) -> String { + let v = if v.abs() < step * 1e-9 { 0.0 } else { v }; + let secs = sections(code); + match secs.get(1) { + Some(neg) if v < 0.0 => format_section(-v, neg, step), + _ => format_section(v, secs.first().copied().unwrap_or(""), step), + } +} + +/// `v` in one section of a format code. +fn format_section(v: f64, section: &str, step: f64) -> String { + let toks = format_toks(section); + let is_num = |t: &Tok| matches!(t, Tok::Num(_) | Tok::Exp(_)); + let lit = |r: &[Tok]| -> String { + r.iter() + .filter_map(|t| match t { + Tok::Lit(l) => Some(l.as_str()), + _ => None, + }) + .collect() + }; + let (Some(s), Some(e)) = (toks.iter().position(is_num), toks.iter().rposition(is_num)) else { return general(v, step) }; + let body = toks.get(s..=e).unwrap_or(&[]); + let (pre, suf) = (lit(toks.get(..s).unwrap_or(&[])), lit(toks.get(e + 1..).unwrap_or(&[]))); + let digits = |r: &[Tok]| -> String { r.iter().filter_map(|t| if let Tok::Num(c) = t { Some(*c) } else { None }).collect() }; + if let Some(k) = body.iter().position(|t| matches!(t, Tok::Exp(_))) { + let plus = matches!(body.get(k), Some(Tok::Exp(true))); + let mantissa = digits(body.get(..k).unwrap_or(&[])); + let exp_digits = digits(body.get(k + 1..).unwrap_or(&[])).len(); + return format!("{pre}{}{suf}", scientific(v, decimals(&mantissa), exp_digits, plus)); + } + let body = digits(body); + let pct = pre.contains('%') || suf.contains('%'); + let v = if pct { v * 100.0 } else { v }; + let digits = format!("{:.*}", decimals(&body), v.abs()); + let (ip, fp) = match digits.split_once('.') { + Some((a, b)) => (a.to_string(), format!(".{b}")), + None => (digits.clone(), String::new()), + }; + let mut grouped = String::new(); + for (k, ch) in ip.chars().enumerate() { + if body.contains(',') && k > 0 && (ip.len() - k) % 3 == 0 { + grouped.push(','); + } + grouped.push(ch); + } + let sign = if v < 0.0 && digits.chars().any(|c| c != '0' && c != '.') { "-" } else { "" }; + format!("{sign}{pre}{grouped}{fp}{suf}") +} + +/// Decimal places of a number body (`#,##0.00` has 2). +fn decimals(body: &str) -> usize { + body.split_once('.').map_or(0, |(_, d)| d.chars().filter(|c| matches!(c, '0' | '#')).count()).min(12) +} + +/// `v` with no number format (`General`): as many decimals as the tick step needs. +fn general(v: f64, step: f64) -> String { + let mut d = 0; + while d < 6 && (step * 10f64.powi(d)).fract().abs() > 1e-9 { + d += 1; + } + let s = format!("{:.*}", d as usize, v); + if s.trim_start_matches(['-', '0', '.']).is_empty() { s.trim_start_matches('-').to_string() } else { s } +} + +/// `v` in scientific notation: `decimals` mantissa decimals, at least `exp_digits` exponent +/// digits, and a `+` on positive exponents when `plus` (`0.00E+00`: 1.23E+04). +fn scientific(v: f64, decimals: usize, exp_digits: usize, plus: bool) -> String { + let s = format!("{:.*e}", decimals, v.abs()); + let (mantissa, exp) = s.split_once('e').unwrap_or((s.as_str(), "0")); + let exp: i32 = if v == 0.0 { 0 } else { exp.parse().unwrap_or(0) }; + let sign = if exp < 0 { + "-" + } else if plus { + "+" + } else { + "" + }; + let minus = if v < 0.0 { "-" } else { "" }; + format!("{minus}{mantissa}E{sign}{:0w$}", exp.unsigned_abs(), w = exp_digits.clamp(1, 4)) +} + +/// A moment as Excel shows a serial day number. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Stamp { + pub(super) y: i64, + pub(super) m: u32, + pub(super) d: u32, + /// Day of the week, 0 = Sunday. + weekday: u32, + /// Seconds into the day. + secs: u32, +} + +/// The moment of Excel serial day `serial` as Excel shows it, from 0 ("January 0, 1900") to +/// 31 December 9999. In the 1900 system serial 60 is Excel's phantom 29 February 1900, and +/// serials before it count from 31 December 1899; in the 1904 system (`date1904`) serial 0 is +/// 1 January 1904. `None` outside that range. +pub(super) fn excel_stamp(serial: f64, date1904: bool) -> Option { + let serial = if date1904 { serial + 1462.0 } else { serial }; + if !(0.0..2_958_466.0).contains(&serial) { + return None; + } + let whole = serial.floor(); + let secs = (((serial - whole) * 86_400.0).round() as u32).min(86_399); + let day = whole as i64; + let (y, m, d) = match day { + 0 => (1900, 1, 0), + 60 => (1900, 2, 29), + 1..=59 => civil(day + 1), + _ => civil(day), + }; + // Excel's weekdays count serial 1 as a Sunday. + Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs }) +} + +/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (an Excel serial +/// day number). Serial `n` is `n - 25_569` days from 1970-01-01. +/// +/// The caller's serial guard keeps `n` inside chrono's range, but the Gregorian calendar repeats +/// every 400 years (146_097 days), so this stays exact for any `n` without hand-rolled calendar +/// math: chrono converts the day within its cycle and whole cycles go back into the year (an +/// i128 so the extremes can't overflow). 1970-01-01 is `num_days_from_ce` 719_163, so serial `n` +/// is CE day `n - 25_569 + 719_163`. +pub(super) fn civil(n: i64) -> (i64, u32, u32) { + use chrono::Datelike; + // CE day of serial `n`, in i128 so no `n` overflows. + let from_ce = i128::from(n) - 25_569 + 719_163; + let (cycles, rest) = (from_ce.div_euclid(146_097), from_ce.rem_euclid(146_097)); + match i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(date) => { + let y = i128::from(date.year()) + cycles * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) + } + // Unreachable: every `rest` in 0..146_097 is a valid CE day. + None => (1900, 1, 1), + } +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// Whether a format code (outside its quoted and bracketed parts) has date or time fields. +pub(super) fn is_date_code(code: &str) -> bool { + format_toks_raw(code).any(|c| matches!(c.to_ascii_lowercase(), 'y' | 'd' | 'm' | 'h' | 's')) +} + +/// The characters of a format code's first section outside quotes and brackets. +fn format_toks_raw(code: &str) -> impl Iterator + '_ { + let mut quoted = false; + let mut bracket = false; + first_section(code).chars().filter(move |c| match c { + '"' => { + quoted = !quoted; + false + } + '[' if !quoted => { + bracket = true; + false + } + ']' if bracket => { + bracket = false; + false + } + _ => !quoted && !bracket, + }) +} + +/// A piece of a date format: literal text, a field (`y`, `m`, `d`, `h`, `s`, or `n` for minutes) +/// with its length, or AM/PM (`AM/PM` when true, `A/P` when false). +enum DTok { + Lit(String), + Field(char, usize), + AmPm(bool), +} + +/// The pieces of a date format's first section. `m` or `mm` right after an hour or right before +/// a seconds field is minutes. +fn date_toks(code: &str) -> Vec { + let section: Vec = first_section(code).chars().collect(); + let mut toks = Vec::new(); + let mut i = 0; + while let Some(&c) = section.get(i) { + let at = i; + i += 1; + match c { + '"' => { + let lit: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != '"').collect(); + i += lit.chars().count() + 1; + toks.push(DTok::Lit(lit)); + } + '[' => { + let inner: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != ']').collect(); + i += inner.chars().count() + 1; + toks.extend(currency(&inner).map(|s| DTok::Lit(s.to_string()))); + } + '\\' | '_' | '*' => { + let next = section.get(i).copied(); + i += 1; + match (c, next) { + ('\\', Some(n)) => toks.push(DTok::Lit(n.to_string())), + ('_', _) => toks.push(DTok::Lit(" ".to_string())), + _ => {} + } + } + 'a' | 'A' if starts_ci(§ion, at, "AM/PM") => { + i += 4; + toks.push(DTok::AmPm(true)); + } + 'a' | 'A' if starts_ci(§ion, at, "A/P") => { + i += 2; + toks.push(DTok::AmPm(false)); + } + _ if matches!(c.to_ascii_lowercase(), 'y' | 'm' | 'd' | 'h' | 's') => { + let mut n = 1; + while section.get(i).is_some_and(|q| q.eq_ignore_ascii_case(&c)) { + i += 1; + n += 1; + } + toks.push(DTok::Field(c.to_ascii_lowercase(), n)); + } + c => toks.push(DTok::Lit(c.to_string())), + } + } + mark_minutes(&mut toks); + toks +} + +/// Whether `chars` from `at` on start with `pat`, ignoring ASCII case. +fn starts_ci(chars: &[char], at: usize, pat: &str) -> bool { + pat.chars().enumerate().all(|(k, p)| chars.get(at + k).is_some_and(|c| c.eq_ignore_ascii_case(&p))) +} + +/// Turns the `m`/`mm` fields that mean minutes (after an hour, or before seconds) into `n`. +fn mark_minutes(toks: &mut [DTok]) { + let fields: Vec = toks.iter().enumerate().filter(|(_, t)| matches!(t, DTok::Field(..))).map(|(k, _)| k).collect(); + let kind = |k: Option<&usize>, toks: &[DTok]| match k.and_then(|k| toks.get(*k)) { + Some(DTok::Field(c, _)) => Some(*c), + _ => None, + }; + for (j, k) in fields.iter().enumerate() { + let after_hour = j.checked_sub(1).and_then(|p| kind(fields.get(p), toks)) == Some('h'); + let before_secs = kind(fields.get(j + 1), toks) == Some('s'); + if let Some(DTok::Field(c @ 'm', n)) = toks.get_mut(*k) + && *n <= 2 + && (after_hour || before_secs) + { + *c = 'n'; + } + } +} + +/// A moment in a date format: day, month, year, weekday (`ddd`, `dddd`), hour, minute and +/// second fields replaced (12-hour with AM/PM); quoted text and currency kept, other brackets +/// dropped. +pub(super) fn date_text(t: Stamp, code: &str) -> String { + let toks = date_toks(code); + let twelve = toks.iter().any(|k| matches!(k, DTok::AmPm(_))); + let hour = t.secs / 3600; + let mut out = String::new(); + for tok in &toks { + match tok { + DTok::Lit(s) => out.push_str(s), + DTok::AmPm(long) => out.push_str(match (hour < 12, long) { + (true, true) => "AM", + (false, true) => "PM", + (true, false) => "A", + (false, false) => "P", + }), + DTok::Field(c, n) => out.push_str(&field_text(t, *c, *n, twelve)), + } + } + out +} + +/// One date or time field (`c` as in [`DTok::Field`], `n` letters long). +fn field_text(t: Stamp, c: char, n: usize, twelve: bool) -> String { + let month = MONTHS.get((t.m as usize).clamp(1, 12) - 1).copied().unwrap_or(""); + let weekday = WEEKDAYS.get(t.weekday as usize).copied().unwrap_or(""); + let pad = |v: u32| if n >= 2 { format!("{v:02}") } else { v.to_string() }; + let hour = t.secs / 3600; + match c { + 'y' if n <= 2 => format!("{:02}", t.y.rem_euclid(100)), + 'y' => format!("{:04}", t.y), + 'd' if n <= 2 => pad(t.d), + 'd' if n == 3 => weekday.get(..3).unwrap_or(weekday).to_string(), + 'd' => weekday.to_string(), + 'm' if n <= 2 => pad(t.m), + 'm' if n == 3 => month.get(..3).unwrap_or(month).to_string(), + 'm' if n == 4 => month.to_string(), + 'm' => month.get(..1).unwrap_or(month).to_string(), + 'h' if twelve => pad(if hour.is_multiple_of(12) { 12 } else { hour % 12 }), + 'h' => pad(hour), + 'n' => pad(t.secs / 60 % 60), + _ => pad(t.secs % 60), + } +} diff --git a/crates/docx/src/read/chart/model.rs b/crates/docx/src/read/chart/model.rs new file mode 100644 index 000000000..807e9e6ef --- /dev/null +++ b/crates/docx/src/read/chart/model.rs @@ -0,0 +1,86 @@ +use super::*; + +/// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in +/// white on the fill) or just beside it (above, or right of a horizontal bar's end). +pub(super) struct DataLabel { + pub(super) x: f32, + pub(super) y: f32, + pub(super) text: String, + pub(super) inside: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Kind { + Bar, + Line, + Area, + Pie, + Doughnut, + Scatter, + Radar, +} + +impl Kind { + pub(super) fn of(name: &str) -> Option { + Some(match name { + "c:barChart" => Kind::Bar, + "c:lineChart" => Kind::Line, + "c:areaChart" => Kind::Area, + "c:pieChart" => Kind::Pie, + "c:doughnutChart" => Kind::Doughnut, + "c:scatterChart" => Kind::Scatter, + "c:radarChart" => Kind::Radar, + _ => return None, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Grouping { + Clustered, + Stacked, + Percent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Pos { + Right, + Left, + Top, + Bottom, +} + +/// One value axis (or category axis) as the file states it. +#[derive(Default)] +pub(super) struct Axis { + pub(super) deleted: bool, + pub(super) grid: bool, + pub(super) line: bool, + /// `c:crossBetween val="midCat"`: categories sit on the tick marks, not between them. + pub(super) mid: bool, + pub(super) min: Option, + pub(super) max: Option, + /// Number format of the tick labels, when not linked to the source data. + pub(super) fmt: Option, +} + +pub(super) struct Series { + pub(super) name: String, + pub(super) color: Option, + pub(super) line_w: f32, + /// Whether the series is drawn as a line (scatter series without one get markers). + pub(super) line: bool, + pub(super) values: Vec>, + /// Scatter x values (index + 1 when the file has none). + pub(super) xs: Vec>, + /// Per-point colours from `c:dPt`. + pub(super) points: Vec>, + pub(super) fmt: Option, + /// Format code of the scatter x values' cache. + pub(super) x_fmt: Option, + /// Data labels on or off for this series (`c:dLbls` of its own), when it says. + pub(super) labels: Option, + /// The values as the file has them, for data labels (`values` become shares in a 100% + /// stacked chart); empty when the series shows no labels. + pub(super) raw: Vec>, +} diff --git a/crates/docx/src/read/chart/parse.rs b/crates/docx/src/read/chart/parse.rs new file mode 100644 index 000000000..5b6551268 --- /dev/null +++ b/crates/docx/src/read/chart/parse.rs @@ -0,0 +1,336 @@ +use super::format::{date_text, excel_stamp, format_value, is_date_code}; +use super::model::{Axis, Grouping, Kind, Pos, Series}; +use super::plot::label; +use super::*; + +impl Chart { + pub(super) fn parse(space: &El, theme: &[Rgb]) -> Option { + let chart = space.child("c:chart")?; + let plot = chart.child("c:plotArea")?; + let ty = plot.els().find(|e| Kind::of(&e.name).is_some())?; + let kind = Kind::of(&ty.name)?; + let scatter = kind == Kind::Scatter; + let date1904 = space.child("c:date1904").is_some_and(|d| d.attr("val").is_none_or(truthy)); + let mut series = read_series(ty, scatter, theme); + if series.is_empty() { + return None; + } + let cats = ty.children("c:ser").find_map(|s| s.child("c:cat")).map(|c| labels(Some(c), MAX_POINTS, date1904)).unwrap_or_default(); + let n = if kind == Kind::Radar { + series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()).min(MAX_SPOKES) + } else if matches!(kind, Kind::Pie | Kind::Doughnut) { + series.first().map_or(0, |s| s.values.len()).max(cats.len()) + } else { + series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()) + }; + // Every series is padded to `n` points, so keep only as many as the cell budget holds. + if let Some(keep) = MAX_CELLS.checked_div(n) { + series.truncate(keep.max(1)); + } + if kind == Kind::Radar { + series.truncate(MAX_RADAR_SERIES); + } + let grouping = match ty.child("c:grouping").and_then(|g| g.attr("val")) { + Some("stacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Stacked, + Some("percentStacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Percent, + _ => Grouping::Clustered, + }; + let mut cats = cats; + cats.resize(n, String::new()); + let radar_style = ty.child("c:radarStyle").and_then(|r| r.attr("val")); + let type_labels = show_values(ty.child("c:dLbls")); + for s in &mut series { + s.values.resize(n, None); + s.xs.resize(n, None); + s.labels = Some(s.labels.or(type_labels).unwrap_or(false)); + if s.labels == Some(true) { + s.raw = s.values.clone(); + } + } + if grouping == Grouping::Percent { + to_percent(&mut series, n); + } + let (cat, val) = chart_axes(plot, scatter); + let val_fmt = if grouping == Grouping::Percent { + "0%".to_string() + } else { + val.fmt.clone().or_else(|| series.iter().find_map(|s| s.fmt.clone())).unwrap_or_else(|| "General".to_string()) + }; + let num = |n: &str| ty.child(n).and_then(|g| g.attr("val")).and_then(int); + Some(Chart { + kind, + horizontal: kind == Kind::Bar && ty.child("c:barDir").and_then(|d| d.attr("val")) == Some("bar"), + grouping, + gap: num("c:gapWidth").unwrap_or(150).clamp(0, 500) as f32 / 100.0, + overlap: num("c:overlap").unwrap_or(0).clamp(-100, 100) as f32 / 100.0, + hole: num("c:holeSize").unwrap_or(50).clamp(10, 90) as f32 / 100.0, + first_angle: num("c:firstSliceAng").unwrap_or(0).clamp(0, 360) as f64, + radar_fill: radar_style == Some("filled"), + radar_marks: radar_style == Some("marker"), + vary: matches!(kind, Kind::Pie | Kind::Doughnut) && ty.child("c:varyColors").and_then(|v| v.attr("val")).is_none_or(truthy), + n, + cats, + title: title_of(chart, &series), + series, + accents: (4..10).map(|i| slot(theme, i)).collect(), + legend: legend_pos(chart), + cat, + val, + text_size: text_size(space), + val_fmt, + date1904, + plot_border: plot.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| fill_colour(l, theme)), + }) + } + + pub(super) fn stacked(&self) -> bool { + self.grouping != Grouping::Clustered + } + + fn accent(&self, i: usize) -> Rgb { + self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK) + } + + pub(super) fn series_colour(&self, j: usize) -> Rgb { + self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j)) + } + + /// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or + /// the series colour. + pub(super) fn point_colour(&self, j: usize, i: usize) -> Rgb { + if let Some(Some(c)) = self.series.get(j).and_then(|s| s.points.get(i)) { + return *c; + } + if self.vary { self.accent(i) } else { self.series_colour(j) } + } + + pub(super) fn legend_entries(&self) -> Vec<(String, Rgb)> { + if matches!(self.kind, Kind::Pie | Kind::Doughnut) { + return self + .cats + .iter() + .enumerate() + .map(|(i, c)| (if c.is_empty() { (i + 1).to_string() } else { c.clone() }, self.point_colour(0, i))) + .collect(); + } + self.series + .iter() + .enumerate() + .map(|(j, s)| (if s.name.is_empty() { format!("Series {}", j + 1) } else { s.name.clone() }, self.series_colour(j))) + .collect() + } +} + +/// The series of a chart type element, within the chart's cell budget. +pub(super) fn read_series(ty: &El, scatter: bool, theme: &[Rgb]) -> Vec { + let mut budget = MAX_CELLS; + let mut series = Vec::new(); + for ser in ty.children("c:ser").take(MAX_SERIES) { + let s = Series::parse(ser, scatter, theme, budget.min(MAX_POINTS)); + budget = budget.saturating_sub(s.values.len().max(s.xs.len())); + series.push(s); + } + series +} + +/// Values as their share of each category's total (100% stacked charts). +fn to_percent(series: &mut [Series], n: usize) { + for i in 0..n { + let total: f64 = series.iter().map(|s| s.values.get(i).copied().flatten().map_or(0.0, f64::abs)).sum(); + for s in series.iter_mut() { + if let Some(Some(v)) = s.values.get_mut(i) { + *v = if total > 0.0 { *v / total } else { 0.0 }; + } + } + } +} + +/// The category axis (scatter: the horizontal value axis) and the value axis of a plot area. +fn chart_axes(plot: &El, scatter: bool) -> (Axis, Axis) { + let vals_ax: Vec<&El> = plot.children("c:valAx").collect(); + let horiz_pos = |a: &&El| matches!(a.child("c:axPos").and_then(|p| p.attr("val")), Some("b" | "t")); + let (val_el, x_el) = if scatter { + let x = vals_ax.iter().copied().find(horiz_pos).or_else(|| vals_ax.first().copied()); + let y = vals_ax.iter().copied().find(|a| !horiz_pos(a)).or_else(|| vals_ax.iter().copied().find(|a| Some(*a) != x)); + (y, x) + } else { + (vals_ax.first().copied(), None) + }; + let cat_el = plot.els().find(|e| matches!(e.name.as_str(), "c:catAx" | "c:dateAx")); + (axis(if scatter { x_el } else { cat_el }), axis(val_el)) +} + +/// The chart's title. An empty `c:title` shows the series name (Word's automatic title of a +/// one-series chart) unless the automatic title is deleted. +pub(super) fn title_of(chart: &El, series: &[Series]) -> Option { + let auto_deleted = chart.child("c:autoTitleDeleted").and_then(|a| a.attr("val")).is_some_and(truthy); + let auto = if !auto_deleted && series.len() == 1 { series.first().map(|s| s.name.clone()).filter(|s| !s.is_empty()) } else { None }; + chart.child("c:title").and_then(|t| title_text(t).or(auto)) +} + +/// Whether a `c:dLbls` shows the values (`None` when there's none, or it doesn't say). +fn show_values(d: Option<&El>) -> Option { + let d = d?; + if d.child("c:delete").and_then(|e| e.attr("val")).is_some_and(truthy) { + return Some(false); + } + d.child("c:showVal").map(|v| v.attr("val").is_none_or(truthy)) +} + +fn legend_pos(chart: &El) -> Option { + chart.child("c:legend").map(|l| match l.child("c:legendPos").and_then(|p| p.attr("val")).unwrap_or("r") { + "l" => Pos::Left, + "t" | "tr" => Pos::Top, + "b" => Pos::Bottom, + _ => Pos::Right, + }) +} + +/// The chart's text size (its `c:txPr` default run size), points. +pub(super) fn text_size(space: &El) -> f32 { + space + .child("c:txPr") + .and_then(|t| t.find("a:defRPr")) + .and_then(|d| d.attr("sz")) + .and_then(int) + .map_or(FONT, |v| (v as f32 / 100.0).clamp(6.0, 24.0)) +} + +impl Series { + fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series { + let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default(); + let sp = ser.child("c:spPr"); + let ln = sp.and_then(|p| p.child("a:ln")); + let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme)); + let color = sp.and_then(|p| fill_colour(p, theme)).or_else(|| ln.and_then(|l| fill_colour(l, theme))).or(marker); + let line_w = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).map_or(LINE_W, |w| w.clamp(0.0, 20.0)); + let line = ln.is_none_or(|l| l.child("a:noFill").is_none()); + let (values, fmt) = numbers(ser.child(if scatter { "c:yVal" } else { "c:val" }), cap); + let (mut xs, x_fmt) = if scatter { numbers(ser.child("c:xVal"), cap) } else { (Vec::new(), None) }; + if scatter && xs.is_empty() { + xs = (1..=values.len()).map(|i| Some(i as f64)).collect(); + } + let mut points = vec![None; values.len()]; + for dpt in ser.children("c:dPt") { + let i = dpt.child("c:idx").and_then(|e| e.attr("val")).and_then(int).and_then(|i| usize::try_from(i).ok()); + let c = dpt.child("c:spPr").and_then(|p| fill_colour(p, theme)); + if let Some(slot) = i.and_then(|i| points.get_mut(i)) { + *slot = c; + } + } + let labels = show_values(ser.child("c:dLbls")); + Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() } + } +} + +/// Longest number-format code honoured (the limit spreadsheet apps use); longer ones are ignored. +const MAX_FORMAT_CODE: usize = 255; + +/// Category labels of a `c:cat` (string or number cache), capped. Numbers show in the cache's +/// format code (dates included); text and `General` numbers as they are stored. +pub(super) fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec { + let Some(el) = el else { return Vec::new() }; + let Some(cache) = el.find("c:strCache").or_else(|| el.find("c:numCache")).or_else(|| el.find("c:strLit")).or_else(|| el.find("c:numLit")) else { + return Vec::new(); + }; + let code = + cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE && !f.eq_ignore_ascii_case("General")); + pts(cache, cap) + .into_iter() + .map(|p| { + let text = p.unwrap_or_default(); + match (code.as_deref(), text.trim().parse::()) { + (Some(code), Ok(v)) if v.is_finite() => label(&category_text(v, code, 1.0, date1904)), + _ => label(&text), + } + }) + .collect() +} + +/// A number in its format code: a date or time for date codes (serial days, from 1904 when +/// `date1904`), else as [`format_value`] with tick step `step`. +pub(super) fn category_text(v: f64, code: &str, step: f64, date1904: bool) -> String { + match excel_stamp(v, date1904) { + Some(t) if is_date_code(code) => date_text(t, code), + _ => format_value(v, code, step), + } +} + +/// Values of a `c:val`-style element (number cache or literal), capped; with its format code. +pub(super) fn numbers(el: Option<&El>, cap: usize) -> (Vec>, Option) { + let Some(cache) = el.and_then(|e| e.find("c:numCache").or_else(|| e.find("c:numLit"))) else { + return (Vec::new(), None); + }; + let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE); + let vals = pts(cache, cap).into_iter().map(|p| p.and_then(|t| t.trim().parse::().ok()).filter(|v| v.is_finite())).collect(); + (vals, fmt) +} + +/// The `c:pt` texts of a cache by index. Indexes and counts from the file are capped. +fn pts(cache: &El, cap: usize) -> Vec> { + let declared = cache.child("c:ptCount").and_then(|c| c.attr("val")).and_then(int).unwrap_or(0).clamp(0, cap as i64) as usize; + let mut out: Vec> = vec![None; declared]; + for p in cache.children("c:pt") { + let Some(i) = p.attr("idx").and_then(int).and_then(|i| usize::try_from(i).ok()) else { continue }; + if i >= cap { + continue; + } + if out.len() <= i { + out.resize(i + 1, None); + } + if let Some(slot) = out.get_mut(i) { + *slot = p.child("c:v").map(|v| v.text()); + } + } + out +} + +pub(super) fn axis(a: Option<&El>) -> Axis { + let Some(a) = a else { return Axis { deleted: true, ..Default::default() } }; + let scaling = a.child("c:scaling"); + let num = + |n: &str| scaling.and_then(|s| s.child(n)).and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + Axis { + deleted: a.child("c:delete").and_then(|d| d.attr("val")).is_some_and(truthy), + grid: a.child("c:majorGridlines").is_some(), + line: a.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| l.child("a:noFill")).is_none(), + mid: a.child("c:crossBetween").and_then(|c| c.attr("val")) == Some("midCat"), + min: num("c:min"), + max: num("c:max"), + fmt: a + .child("c:numFmt") + .filter(|f| f.attr("sourceLinked") != Some("1")) + .and_then(|f| f.attr("formatCode")) + .filter(|f| f.len() <= MAX_FORMAT_CODE) + .map(str::to_string), + } +} + +fn title_text(t: &El) -> Option { + let tx = t.child("c:tx")?; + let s = match tx.child("c:rich") { + Some(rich) => { + let mut s = String::new(); + rich_into(rich, &mut s); + s + } + None => tx.find("c:v").map(|v| v.text()).unwrap_or_default(), + }; + let s = label(s.trim()); + (!s.is_empty()).then_some(s) +} + +/// The `a:t` runs under an element, in order. +fn rich_into(e: &El, s: &mut String) { + for c in e.els() { + if c.name == "a:t" { + s.push_str(&c.text()); + } else { + rich_into(c, s); + } + } +} + +fn truthy(v: &str) -> bool { + matches!(v.trim(), "1" | "true") +} diff --git a/crates/docx/src/read/chart/plot.rs b/crates/docx/src/read/chart/plot.rs new file mode 100644 index 000000000..5857f51e7 --- /dev/null +++ b/crates/docx/src/read/chart/plot.rs @@ -0,0 +1,463 @@ +use super::canvas::{Rect, fill, rect_segs, runs, seg, stroke, text, text_at, text_w, widest, wrap}; +use super::format::format_value; +use super::model::{DataLabel, Grouping, Kind, Series}; +use super::parse::category_text; +use super::polar::{draw_data_labels, label_of}; +use super::*; + +pub(super) fn label(s: &str) -> String { + s.chars().take(MAX_LABEL).collect() +} + +/// Chart area: the chart space's fill and border (white and borderless by default). +pub(super) fn backdrop(out: &mut Vec, space: &El, theme: &[Rgb], w: f32, h: f32) { + let sp = space.child("c:spPr"); + let fill = match sp { + Some(p) if p.child("a:noFill").is_some() => None, + Some(p) => fill_colour(p, theme).or(Some(Rgb::WHITE)), + None => Some(Rgb::WHITE), + }; + let ln = sp.and_then(|p| p.child("a:ln")); + let stroke = ln.and_then(|l| fill_colour(l, theme)); + let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.75).clamp(0.0, 20.0); + if fill.is_some() || stroke.is_some() { + out.push(GraphicItem::Shape { rect: [0.0, 0.0, w, h], kind: ShapeKind::Rectangle, fill, stroke, stroke_width }); + } +} + +/// An axis scale: `lo` to `hi` in steps of `step`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Scale { + pub(super) lo: f64, + pub(super) hi: f64, + pub(super) step: f64, +} + +impl Scale { + pub(super) fn ticks(&self) -> Vec { + let n = (((self.hi - self.lo) / self.step + 1e-9).floor().max(0.0) as usize).min(MAX_TICKS); + (0..=n).map(|k| self.lo + k as f64 * self.step).filter(|t| *t <= self.hi + 1e-9).collect() + } +} + +/// A scale for data `dlo..dhi` (zero included when `zero`), as Excel and Word pick it: an end +/// away from zero sits 5% of the data span beyond the data (an end at zero stays there), rounded +/// out to a step of 1, 2, 2.5, 5 or 10 times a power of ten (about seven steps). `min`/`max` +/// from the file replace the ends. +pub(super) fn nice_scale(dlo: f64, dhi: f64, zero: bool, min: Option, max: Option) -> Scale { + let (dlo, dhi) = if dhi > dlo { (dlo, dhi) } else { (dlo, dlo + 1.0) }; + let span = dhi - dlo; + // With zero, the ends reach it: all-negative data still starts its bars at zero. + let (ends_lo, ends_hi) = if zero { (dlo.min(0.0), dhi.max(0.0)) } else { (dlo, dhi) }; + let lo = min.unwrap_or(if ends_lo < 0.0 { ends_lo - 0.05 * span } else { ends_lo }); + let hi = max.unwrap_or(if ends_hi > 0.0 { ends_hi + 0.05 * span } else { ends_hi }); + if !(hi > lo && hi.is_finite() && lo.is_finite()) { + return Scale { lo: 0.0, hi: 1.0, step: 0.2 }; + } + let raw = (hi - lo) / 7.5; + let mag = 10f64.powf(raw.log10().floor()); + let step = [1.0, 2.0, 2.5, 5.0, 10.0].iter().map(|m| m * mag).find(|s| *s >= raw).unwrap_or(10.0 * mag); + let lo_r = if min.is_some() { lo } else { ((lo / step) + 1e-9).floor() * step }; + let hi_r = if max.is_some() { hi } else { ((hi / step) - 1e-9).ceil() * step }; + if !(step.is_finite() && step > 0.0 && lo_r.is_finite() && hi_r.is_finite() && hi_r > lo_r) { + return Scale { lo: 0.0, hi: 1.0, step: 0.2 }; + } + Scale { lo: lo_r, hi: hi_r, step } +} + +/// The plot rectangle and the scales that map values and categories onto it. +struct Frame { + plot: Rect, + horiz: bool, + n: usize, + vs: Scale, + xs: Option, + mid: bool, +} + +impl Frame { + /// Width of one category band. + fn band(&self) -> f32 { + let len = if self.horiz { self.plot.h } else { self.plot.w }; + len / self.n.max(1) as f32 + } + + /// Start of category `i`'s band: its left edge, or for bars its top edge (category 0 at the + /// bottom). + fn band_start(&self, i: usize) -> f32 { + let k = i as f32; + if self.horiz { self.plot.bottom() - (k + 1.0) * self.band() } else { self.plot.x + k * self.band() } + } + + /// A value's position along the value direction: y for columns, x for bars. + fn val(&self, v: f64) -> f32 { + let t = ((v - self.vs.lo) / (self.vs.hi - self.vs.lo)).clamp(0.0, 1.0) as f32; + if self.horiz { self.plot.x + t * self.plot.w } else { self.plot.bottom() - t * self.plot.h } + } + + /// x of category `i` for line and area points. + fn cat_x(&self, i: usize) -> f32 { + if !self.mid { + return self.plot.x + (i as f32 + 0.5) * self.band(); + } + if self.n <= 1 { self.plot.x + self.plot.w / 2.0 } else { self.plot.x + self.plot.w * i as f32 / (self.n - 1) as f32 } + } + + /// x of a scatter point. + fn scat_x(&self, x: f64) -> f32 { + match self.xs { + Some(s) => self.plot.x + ((x - s.lo) / (s.hi - s.lo)).clamp(0.0, 1.0) as f32 * self.plot.w, + None => self.plot.x, + } + } +} + +/// The value scale and, for scatter charts, the x scale, with their ticks and tick texts. +pub(super) struct Axes { + pub(super) vs: Scale, + vticks: Vec<(f64, String)>, + xs: Option, + xticks: Vec<(f64, String)>, +} + +impl Axes { + pub(super) fn of(c: &Chart) -> Axes { + let (dlo, dhi) = extent(c); + let (vmin, vmax) = if c.grouping == Grouping::Percent { (Some(0.0), Some(1.0)) } else { (c.val.min, c.val.max) }; + // Lines and scatters don't need zero for a narrow range (1000..1050 stays on its own scale). + let zero = !matches!(c.kind, Kind::Line | Kind::Scatter) || !narrow(dlo, dhi); + let vs = nice_scale(dlo, dhi, zero, vmin, vmax); + let vticks = vs.ticks().into_iter().map(|t| (t, format_value(t, &c.val_fmt, vs.step))).collect(); + let xs = (c.kind == Kind::Scatter).then(|| { + let (xl, xh) = x_extent(c); + nice_scale(xl, xh, false, c.cat.min, c.cat.max) + }); + // Scatter x values show in the axis's own format, else their cache's (dates included). + let xfmt = c.cat.fmt.clone().or_else(|| c.series.iter().find_map(|s| s.x_fmt.clone())).unwrap_or_else(|| "General".to_string()); + let xticks = xs.map(|s| s.ticks().into_iter().map(|t| (t, category_text(t, &xfmt, s.step, c.date1904))).collect()).unwrap_or_default(); + Axes { vs, vticks, xs, xticks } + } +} + +/// The room around the plot for axis labels, and the category labels as they'll be drawn. +struct Margins { + left: f32, + top: f32, + bottom: f32, + /// Category labels in lines (none for scatter charts or a deleted category axis). + wrapped: Vec>, + /// Every `skip`-th category label is shown under a column chart. + skip: usize, +} + +/// The margins the axis labels need around the plot in `area`. Category labels under a column +/// chart wrap to the band; labels that still don't fit show every k-th category. +fn axis_margins(c: &Chart, ax: &Axes, area: Rect) -> Margins { + let fs = c.text_size; + let lh = fs * LINE; + let scatter = c.kind == Kind::Scatter; + let (cat_on, val_on) = (!c.cat.deleted, !c.val.deleted); + let left = if c.horizontal { + if cat_on { widest(&c.cats, fs).min(area.w * 0.4) + GAP } else { 0.0 } + } else if val_on { + ax.vticks.iter().map(|(_, s)| text_w(s, fs)).fold(0.0, f32::max) + GAP + } else { + 0.0 + }; + let band_w = (area.w - left).max(1.0) / c.n as f32; + let wrapped: Vec> = if cat_on && !scatter { + let width = if c.horizontal { left - GAP } else { band_w }; + c.cats.iter().map(|s| wrap(s, width, fs)).collect() + } else { + Vec::new() + }; + let widest_line = wrapped.iter().flatten().map(|s| text_w(s, fs)).fold(0.0, f32::max); + let skip = ((widest_line / band_w).ceil() as usize).max(1); + let label_lines = wrapped.iter().map(Vec::len).max().unwrap_or(1) as f32; + let bottom = if cat_on && !c.horizontal { + if scatter { lh + GAP } else { label_lines * lh + GAP } + } else if c.horizontal && val_on { + lh + GAP + } else { + 0.0 + }; + let top = if !c.horizontal && val_on { lh / 2.0 } else { 0.0 }; + Margins { left, top, bottom, wrapped, skip } +} + +/// A bar, column, line, area or scatter chart in `area`: gridlines, series, axes, then labels. +pub(super) fn cartesian(out: &mut Vec, c: &Chart, area: Rect) { + if c.n == 0 { + return; + } + let ax = Axes::of(c); + let m = axis_margins(c, &ax, area); + let plot = Rect { x: area.x + m.left, y: area.y + m.top, w: area.w - m.left, h: area.h - m.top - m.bottom }; + if plot.w < 1.0 || plot.h < 1.0 { + return; + } + let g = Frame { plot, horiz: c.horizontal, n: c.n, vs: ax.vs, xs: ax.xs, mid: c.val.mid }; + if c.val.grid { + draw_grid(out, &g, &ax.vticks); + } + let mut labels = Vec::new(); + match c.kind { + Kind::Bar => bars(out, c, &g, &mut labels), + Kind::Line => lines(out, c, &g, &mut labels), + Kind::Area => areas(out, c, &g, &mut labels), + Kind::Scatter => draw_scatter(out, c, &g, &mut labels), + Kind::Pie | Kind::Doughnut | Kind::Radar => {} + } + draw_axes(out, c, &g); + draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar); + if !c.val.deleted { + draw_value_labels(out, c, &g, &ax.vticks, Rect { w: m.left, ..area }); + } + if !c.cat.deleted { + draw_category_labels(out, c, &g, &ax.xticks, &m, area); + } +} + +/// Major gridlines at the value ticks. +fn draw_grid(out: &mut Vec, g: &Frame, ticks: &[(f64, String)]) { + let plot = g.plot; + for (t, _) in ticks { + let p = g.val(*t); + let segs = if g.horiz { seg(p, plot.y, p, plot.bottom()) } else { seg(plot.x, p, plot.right(), p) }; + stroke(out, segs, GRID, 0.75); + } +} + +/// The category axis line, the plot area's outline and the value axis line, as the file shows them. +fn draw_axes(out: &mut Vec, c: &Chart, g: &Frame) { + let plot = g.plot; + if !c.cat.deleted && c.cat.line { + let segs = if g.horiz { seg(plot.x, plot.y, plot.x, plot.bottom()) } else { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) }; + stroke(out, segs, AXIS, 0.75); + } + if let Some(color) = c.plot_border { + stroke(out, rect_segs(plot.x, plot.y, plot.right(), plot.bottom()), color, 0.75); + } + if !c.val.deleted && c.val.line { + let segs = if g.horiz { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) } else { seg(plot.x, plot.y, plot.x, plot.bottom()) }; + stroke(out, segs, AXIS, 0.75); + } +} + +/// Value tick labels: under the plot for bars, else right-aligned in `margin` (the area's left +/// margin). +fn draw_value_labels(out: &mut Vec, c: &Chart, g: &Frame, ticks: &[(f64, String)], margin: Rect) { + let (fs, lh) = (c.text_size, c.text_size * LINE); + for (t, s) in ticks { + let p = g.val(*t); + if g.horiz { + text_at(out, p, g.plot.bottom() + GAP / 2.0, s, fs); + } else { + text(out, Rect { x: margin.x, y: p - lh / 2.0, w: margin.w - GAP, h: lh }, s, fs, TextAlign::Right, false); + } + } +} + +/// Category labels: scatter x ticks under the plot, bar labels in the left margin, column +/// labels under their bands (every `m.skip`-th). +fn draw_category_labels(out: &mut Vec, c: &Chart, g: &Frame, xticks: &[(f64, String)], m: &Margins, area: Rect) { + let (fs, lh) = (c.text_size, c.text_size * LINE); + let plot = g.plot; + if c.kind == Kind::Scatter { + for (t, s) in xticks { + text_at(out, g.scat_x(*t), plot.bottom() + GAP / 2.0, s, fs); + } + } else if g.horiz { + for (i, lines) in m.wrapped.iter().enumerate() { + let top = g.band_start(i) + g.band() / 2.0 - lines.len() as f32 * lh / 2.0; + for (k, s) in lines.iter().enumerate() { + text(out, Rect { x: area.x, y: top + k as f32 * lh, w: m.left - GAP, h: lh }, s, fs, TextAlign::Right, false); + } + } + } else { + let band = g.band(); + for (i, lines) in m.wrapped.iter().enumerate().filter(|(i, _)| i % m.skip == 0) { + for (k, s) in lines.iter().enumerate() { + let y = plot.bottom() + GAP / 2.0 + k as f32 * lh; + text(out, Rect { x: g.cat_x(i) - band / 2.0, y, w: band, h: lh }, s, fs, TextAlign::Center, false); + } + } + } +} + +/// Whether data `lo..hi` is a narrow band away from zero: its span is under a sixth of the end +/// farther from zero (the mirror image for negative data). +pub(super) fn narrow(lo: f64, hi: f64) -> bool { + if lo > 0.0 { + (hi - lo) / hi < 1.0 / 6.0 + } else if hi < 0.0 { + (hi - lo) / lo.abs() < 1.0 / 6.0 + } else { + false + } +} + +/// Lowest and highest value of the plot; stacked series add up per category. +fn extent(c: &Chart) -> (f64, f64) { + let mut pts: Vec = Vec::new(); + if c.stacked() { + for i in 0..c.n { + let (mut p, mut q) = (0.0, 0.0); + for s in &c.series { + match s.values.get(i).copied().flatten() { + Some(v) if v >= 0.0 => p += v, + Some(v) => q += v, + None => {} + } + } + pts.push(p); + pts.push(q); + } + } else { + pts.extend(c.series.iter().flat_map(|s| s.values.iter().flatten().copied())); + } + let lo = pts.iter().copied().fold(f64::INFINITY, f64::min); + let hi = pts.iter().copied().fold(f64::NEG_INFINITY, f64::max); + if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 0.0) } +} + +/// Range of scatter x values (index + 1 where missing). +fn x_extent(c: &Chart) -> (f64, f64) { + let xs: Vec = c.series.iter().flat_map(|s| s.xs.iter().flatten().copied()).collect(); + let lo = xs.iter().copied().fold(f64::INFINITY, f64::min); + let hi = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 1.0) } +} + +fn bars(out: &mut Vec, c: &Chart, g: &Frame, labels: &mut Vec) { + let stacked = c.stacked(); + let slots = if stacked { 1 } else { c.series.len().max(1) }; + let ov = if stacked { 0.0 } else { c.overlap }; + let ns = slots as f32; + let band = g.band(); + let thick = band / (ns - (ns - 1.0) * ov + c.gap); + let group = thick * (ns - (ns - 1.0) * ov); + let mut pos = vec![0.0f64; g.n]; + let mut neg = vec![0.0f64; g.n]; + for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) { + let slot = if stacked { 0 } else { j }; + let slot = (if g.horiz { slots.saturating_sub(1 + slot) } else { slot }) as f32; + for i in 0..g.n { + let Some(v) = s.values.get(i).copied().flatten() else { continue }; + let (v0, v1) = if !stacked { + (0.0, v) + } else if v >= 0.0 { + let Some(p) = pos.get_mut(i) else { continue }; + let b = *p; + *p += v; + (b, *p) + } else { + let Some(q) = neg.get_mut(i) else { continue }; + let b = *q; + *q += v; + (b, *q) + }; + let a = g.band_start(i) + (band - group) / 2.0 + slot * thick * (1.0 - ov); + let (p0, p1) = (g.val(v0), g.val(v1)); + let (p0, p1) = (p0.min(p1), p0.max(p1)); + let segs = if g.horiz { rect_segs(p0, a, p1, a + thick) } else { rect_segs(a, p0, a + thick, p1) }; + fill(out, segs, c.point_colour(j, i)); + if let Some(text) = label_of(s, i) { + let mid = a + thick / 2.0; + let (x, y) = match (g.horiz, stacked) { + (true, true) => ((p0 + p1) / 2.0, mid), + (false, true) => (mid, (p0 + p1) / 2.0), + // Beyond the bar's end: right of a positive bar, above a positive column. + (true, false) => (if v >= 0.0 { p1 } else { p0 }, mid), + (false, false) => (mid, if v >= 0.0 { p0 } else { p1 }), + }; + labels.push(DataLabel { x, y, text, inside: stacked }); + } + } + } +} + +/// Whether a series has any point to draw. +pub(super) fn has_values(s: &Series) -> bool { + s.values.iter().any(Option::is_some) +} + +/// Line series; stacked ones are drawn at the running total of the series up to them. +fn lines(out: &mut Vec, c: &Chart, g: &Frame, labels: &mut Vec) { + let mut total = vec![0.0f64; g.n]; + for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) { + let pts: Vec> = (0..g.n) + .map(|i| { + let v = s.values.get(i).copied().flatten()?; + let y = match total.get_mut(i) { + Some(t) if c.stacked() => { + *t += v; + *t + } + _ => v, + }; + Some((g.cat_x(i), g.val(y))) + }) + .collect(); + for (i, p) in pts.iter().enumerate() { + if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) { + labels.push(DataLabel { x: *x, y: *y, text, inside: false }); + } + } + stroke(out, runs(pts.into_iter()), c.series_colour(j), s.line_w); + } +} + +fn areas(out: &mut Vec, c: &Chart, g: &Frame, labels: &mut Vec) { + let mut base = vec![0.0f64; g.n]; + for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) { + let vals = (0..g.n).map(|i| s.values.get(i).copied().flatten().unwrap_or(0.0)); + let top: Vec = base.iter().zip(vals).map(|(b, v)| b + v).collect(); + let mut segs = Vec::with_capacity(2 * g.n + 1); + for (i, t) in top.iter().enumerate() { + let (x, y) = (g.cat_x(i), g.val(*t)); + segs.push(if i == 0 { PathSeg::Move(x, y) } else { PathSeg::Line(x, y) }); + } + for (i, b) in base.iter().enumerate().rev() { + segs.push(PathSeg::Line(g.cat_x(i), g.val(*b))); + } + segs.push(PathSeg::Close); + fill(out, segs, c.series_colour(j)); + for (i, (t, b)) in top.iter().zip(&base).enumerate() { + if let Some(text) = label_of(s, i) { + labels.push(DataLabel { x: g.cat_x(i), y: g.val((t + b) / 2.0), text, inside: true }); + } + } + if c.stacked() { + base = top; + } + } +} + +/// Scatter series as lines, or as small markers when the series has no line. +fn draw_scatter(out: &mut Vec, c: &Chart, g: &Frame, labels: &mut Vec) { + const MARKER: f32 = 6.0; + for (j, s) in c.series.iter().enumerate() { + let pts: Vec> = + s.xs.iter() + .zip(&s.values) + .map(|(x, y)| match (x, y) { + (Some(x), Some(y)) => Some((g.scat_x(*x), g.val(*y))), + _ => None, + }) + .collect(); + for (i, p) in pts.iter().enumerate() { + if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) { + labels.push(DataLabel { x: *x, y: *y - MARKER / 2.0, text, inside: false }); + } + } + if s.line { + stroke(out, runs(pts.iter().copied()), c.series_colour(j), s.line_w); + continue; + } + for (x, y) in pts.iter().flatten() { + let rect = [x - MARKER / 2.0, y - MARKER / 2.0, MARKER, MARKER]; + out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(c.series_colour(j)), stroke: None, stroke_width: 0.0 }); + } + } +} diff --git a/crates/docx/src/read/chart/polar.rs b/crates/docx/src/read/chart/polar.rs new file mode 100644 index 000000000..f8f4f0b38 --- /dev/null +++ b/crates/docx/src/read/chart/polar.rs @@ -0,0 +1,206 @@ +use super::canvas::{Rect, clip_text, fill, stroke, text, text_w, widest}; +use super::format::format_value; +use super::model::{DataLabel, Kind, Series}; +use super::plot::{Axes, has_values}; +use super::*; + +pub(super) fn pie(out: &mut Vec, c: &Chart, area: Rect) { + let Some(s) = c.series.first() else { return }; + let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); + let total: f64 = vals.iter().sum(); + let r = (area.w.min(area.h) / 2.0 - 2.0) as f64; + if !(total > 0.0 && total.is_finite() && r >= 1.0) { + return; + } + let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64); + let inner = if c.kind == Kind::Doughnut { r * c.hole as f64 } else { 0.0 }; + let mut a = c.first_angle.to_radians(); + let mut labels = Vec::new(); + for (i, v) in vals.iter().enumerate() { + if *v <= 0.0 { + continue; + } + let b = a + v / total * TAU; + let mut segs = Vec::new(); + if inner > 0.0 { + segs.push(pt(polar(cx, cy, r, a))); + arc(&mut segs, (cx, cy, r), a, b); + segs.push(line_to(polar(cx, cy, inner, b))); + arc(&mut segs, (cx, cy, inner), b, a); + } else { + segs.push(pt((cx, cy))); + segs.push(line_to(polar(cx, cy, r, a))); + arc(&mut segs, (cx, cy, r), a, b); + } + segs.push(PathSeg::Close); + fill(out, segs, c.point_colour(0, i)); + if let Some(text) = label_of(s, i) { + // Halfway across the ring (pie: two thirds out from the centre). + let at = if inner > 0.0 { (r + inner) / 2.0 } else { r * 0.65 }; + let (x, y) = polar(cx, cy, at, (a + b) / 2.0); + labels.push(DataLabel { x: x as f32, y: y as f32, text, inside: true }); + } + a = b; + } + draw_data_labels(out, &labels, c.text_size, false); +} + +/// A radar chart in `area`: category spokes from the centre, concentric grid polygons at the value +/// ticks, one polygon per series (filled, or outlined with markers) and the category labels at the +/// spoke ends. The lowest tick is the centre. +pub(super) fn radar(out: &mut Vec, c: &Chart, area: Rect) { + const MARKER: f32 = 5.0; + if c.n == 0 { + return; + } + let fs = c.text_size; + let lh = fs * LINE; + let labelled = c.cats.iter().any(|s| !s.is_empty()); + let side = if labelled { widest(&c.cats, fs).min(area.w * 0.25) + GAP + 2.0 } else { 0.0 }; + let vert = if labelled { lh + GAP } else { 0.0 }; + let r = ((area.w / 2.0 - side).min(area.h / 2.0 - vert) - 2.0) as f64; + if r.is_nan() || r < 8.0 { + return; + } + let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64); + let vs = Axes::of(c).vs; + let span = vs.hi - vs.lo; + let angle = |i: usize| i as f64 / c.n as f64 * TAU; + let at = |v: f64, i: usize| { + let t = if span > 0.0 { ((v - vs.lo) / span).clamp(0.0, 1.0) } else { 0.0 }; + polar(cx, cy, r * t, angle(i)) + }; + let ticks = vs.ticks(); + if c.val.grid { + for t in ticks.iter().skip(1) { + let mut segs: Vec = (0..c.n).map(|i| if i == 0 { pt(at(*t, i)) } else { line_to(at(*t, i)) }).collect(); + segs.push(PathSeg::Close); + stroke(out, segs, GRID, 0.75); + } + } + for i in 0..c.n { + let end = polar(cx, cy, r, angle(i)); + stroke(out, vec![pt((cx, cy)), line_to(end)], GRID, 0.75); + } + let mut labels = Vec::new(); + for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) { + let col = c.series_colour(j); + // A missing value counts as the centre, as Word draws it. + let ring: Vec<(f64, f64)> = (0..c.n).map(|i| at(s.values.get(i).copied().flatten().unwrap_or(vs.lo), i)).collect(); + let mut segs: Vec = ring.iter().enumerate().map(|(i, p)| if i == 0 { pt(*p) } else { line_to(*p) }).collect(); + segs.push(PathSeg::Close); + if c.radar_fill { + fill(out, segs.clone(), col); + } + stroke(out, segs, col, s.line_w); + for (i, p) in ring.iter().enumerate() { + if c.radar_marks && s.values.get(i).copied().flatten().is_some() { + let rect = [p.0 as f32 - MARKER / 2.0, p.1 as f32 - MARKER / 2.0, MARKER, MARKER]; + out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(col), stroke: None, stroke_width: 0.0 }); + } + if let Some(text) = label_of(s, i) { + labels.push(DataLabel { x: p.0 as f32, y: p.1 as f32, text, inside: false }); + } + } + } + draw_data_labels(out, &labels, fs, false); + if !c.val.deleted { + for t in &ticks { + let y = at(*t, 0).1 as f32; + let s = format_value(*t, &c.val_fmt, vs.step); + let w = text_w(&s, fs) + 4.0; + text(out, Rect { x: cx as f32 - GAP / 2.0 - w, y: y - lh / 2.0, w, h: lh }, &s, fs, TextAlign::Right, false); + } + } + if labelled { + for (i, s) in c.cats.iter().enumerate().filter(|(_, s)| !s.is_empty()) { + let a = angle(i); + let (x, y) = polar(cx, cy, r + GAP as f64, a); + let (x, y) = (x as f32, y as f32); + let w = side - GAP; + let txt = clip_text(s, w, fs); + let (sx, sy) = (a.sin(), a.cos()); + let (rect, align) = if sx > 0.2 { + (Rect { x, y: y - lh / 2.0, w, h: lh }, TextAlign::Left) + } else if sx < -0.2 { + (Rect { x: x - w, y: y - lh / 2.0, w, h: lh }, TextAlign::Right) + } else { + let cw = (text_w(&txt, fs) + 4.0).max(1.0); + (Rect { x: x - cw / 2.0, y: if sy > 0.0 { y - lh } else { y }, w: cw, h: lh }, TextAlign::Center) + }; + text(out, rect, &txt, fs, align, false); + } + } +} + +/// The data label of point `i` of series `s`, when it shows one: the value as the file has it, +/// in the series' number format. +pub(super) fn label_of(s: &Series, i: usize) -> Option { + if s.labels != Some(true) { + return None; + } + let v = s.raw.get(i).copied().flatten()?; + let fmt = s.fmt.as_deref().filter(|f| !f.eq_ignore_ascii_case("General")); + Some(match fmt { + Some(f) => format_value(v, f, 1.0), + None => general_value(v), + }) +} + +/// A value in the `General` format: whole numbers without decimals, others with up to six. +fn general_value(v: f64) -> String { + if v.fract() == 0.0 && v.abs() < 1e15 { + return format!("{v:.0}"); + } + let s = format!("{v:.6}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".to_string() } else { s.to_string() } +} + +/// Data labels over the drawn chart. Beside labels sit above their point, or right of it for +/// horizontal bars (`beside_right`). +pub(super) fn draw_data_labels(out: &mut Vec, labels: &[DataLabel], fs: f32, beside_right: bool) { + let lh = fs * LINE; + for l in labels { + let w = text_w(&l.text, fs) + 4.0; + let (rect, align) = if l.inside { + (Rect { x: l.x - w / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Center) + } else if beside_right { + (Rect { x: l.x + GAP / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Left) + } else { + (Rect { x: l.x - w / 2.0, y: l.y - lh, w, h: lh }, TextAlign::Center) + }; + let color = if l.inside { Rgb::WHITE } else { TEXT }; + out.push(GraphicItem::Text { rect: rect.arr(), text: l.text.clone(), size: fs, color, bold: false, align, font: None }); + } +} + +/// A point at clockwise angle `t` (radians from 12 o'clock) on a circle of radius `r`. +fn polar(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) { + (cx + r * t.sin(), cy - r * t.cos()) +} + +fn pt(p: (f64, f64)) -> PathSeg { + PathSeg::Move(p.0 as f32, p.1 as f32) +} + +fn line_to(p: (f64, f64)) -> PathSeg { + PathSeg::Line(p.0 as f32, p.1 as f32) +} + +/// Cubic segments along a circle from angle `a` to `b` (the current point is at `a`). Each piece +/// spans at most a quarter turn. +fn arc(segs: &mut Vec, (cx, cy, r): (f64, f64, f64), a: f64, b: f64) { + let n = (((b - a).abs() / FRAC_PI_2).ceil() as usize).clamp(1, 16); + let d = (b - a) / n as f64; + let k = 4.0 / 3.0 * (d / 4.0).tan(); + for j in 0..n { + let t0 = a + d * j as f64; + let t1 = t0 + d; + let p0 = polar(cx, cy, r, t0); + let p1 = polar(cx, cy, r, t1); + let c1 = (p0.0 + k * r * t0.cos(), p0.1 + k * r * t0.sin()); + let c2 = (p1.0 - k * r * t1.cos(), p1.1 - k * r * t1.sin()); + segs.push(PathSeg::Cubic(c1.0 as f32, c1.1 as f32, c2.0 as f32, c2.1 as f32, p1.0 as f32, p1.1 as f32)); + } +}