refactor(docx): split read/chart.rs into read/chart/

This commit is contained in:
aiongg
2026-10-26 21:16:49 +00:00
parent 80d8a9952a
commit b8ddca30a1
7 changed files with 1693 additions and 1652 deletions
File diff suppressed because it is too large Load Diff
+210
View File
@@ -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<GraphicItem>, 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<GraphicItem>, 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<GraphicItem>, segs: Vec<PathSeg>, 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<GraphicItem>, segs: Vec<PathSeg>, 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<GraphicItem>, 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<PathSeg> {
vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y1)]
}
pub(super) fn rect_segs(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec<PathSeg> {
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<Item = Option<(f32, f32)>>) -> Vec<PathSeg> {
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<String> {
let mut lines: Vec<String> = 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<GraphicItem>, 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<GraphicItem>, 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<GraphicItem>, 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::<f32>() + 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<Vec<(String, Rgb, f32)>> {
let mut rows: Vec<Vec<(String, Rgb, f32)>> = 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
}
+370
View File
@@ -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<Tok> {
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<Stamp> {
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<Item = char> + '_ {
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<DTok> {
let section: Vec<char> = 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(&section, at, "AM/PM") => {
i += 4;
toks.push(DTok::AmPm(true));
}
'a' | 'A' if starts_ci(&section, 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<usize> = 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),
}
}
+86
View File
@@ -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<Kind> {
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<f64>,
pub(super) max: Option<f64>,
/// Number format of the tick labels, when not linked to the source data.
pub(super) fmt: Option<String>,
}
pub(super) struct Series {
pub(super) name: String,
pub(super) color: Option<Rgb>,
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<Option<f64>>,
/// Scatter x values (index + 1 when the file has none).
pub(super) xs: Vec<Option<f64>>,
/// Per-point colours from `c:dPt`.
pub(super) points: Vec<Option<Rgb>>,
pub(super) fmt: Option<String>,
/// Format code of the scatter x values' cache.
pub(super) x_fmt: Option<String>,
/// Data labels on or off for this series (`c:dLbls` of its own), when it says.
pub(super) labels: Option<bool>,
/// 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<Option<f64>>,
}
+336
View File
@@ -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<Chart> {
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<Series> {
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<String> {
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<bool> {
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<Pos> {
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<String> {
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::<f64>()) {
(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<f64>>, Option<String>) {
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::<f64>().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<Option<String>> {
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<Option<String>> = 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::<f64>().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<String> {
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")
}
+463
View File
@@ -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<GraphicItem>, 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<f64> {
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<f64>, max: Option<f64>) -> 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<Scale>,
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<Scale>,
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<Vec<String>>,
/// 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<Vec<String>> = 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<GraphicItem>, 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<GraphicItem>, 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<GraphicItem>, 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<GraphicItem>, 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<GraphicItem>, 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<f64> = 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<f64> = 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<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
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<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
let mut total = vec![0.0f64; g.n];
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
let pts: Vec<Option<(f32, f32)>> = (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<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
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<f64> = 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<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
const MARKER: f32 = 6.0;
for (j, s) in c.series.iter().enumerate() {
let pts: Vec<Option<(f32, f32)>> =
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 });
}
}
}
+206
View File
@@ -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<GraphicItem>, c: &Chart, area: Rect) {
let Some(s) = c.series.first() else { return };
let vals: Vec<f64> = 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<GraphicItem>, 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<PathSeg> = (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<PathSeg> = 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<String> {
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<GraphicItem>, 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<PathSeg>, (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));
}
}