craft-drawingml: model types for fills, lines, text, tables, charts and diagrams (#901)

* Add craft-drawingml: units, colours, preset shapes, custom geometry and text warps

The parts of DrawingML that need no document model, moved from DeckCraft
(60f91f78): crates/color, crates/geom (preset, warp, Xfrm and the EMU
conversions) and crates/pptx/src/custgeom.rs with CustomPath from
crates/model.

The code is unchanged except that custgeom reads craft-xml trees and writes
to a craft-xml Writer instead of DeckCraft's own XML helper, and matches
elements by local name. The 59 tests of the moved code come with it; six
more cover the custom geometry limits.

* drawingml: evaluate a custom geometry's guide lists once per coordinate space

read_cust_geom rebuilt Guides and evaluated avLst and gdLst again for every
path: up to 1,000 paths times up to 10,000 guides, each name copied and
hashed.

Guides are now cached per path coordinate space (w, h). At most 16 spaces
and 4 MiB of guide names are kept per geometry; paths in further spaces are
left out. The first space is always kept, and a geometry without guides is
not capped.

Ported from storytold/deckcraft#1789. A list counts as having guides only
when it has gd children, as that PR's review asked.

* drawingml: the model types for fills, lines, text, tables, charts and diagrams

Moved from DeckCraft (60f91f78) crates/model/src: style.rs, text.rs,
table.rs, chart.rs, numfmt.rs and trendline.rs, plus Diagram, DiagramNode
and the MediaId and SlideId newtypes from lib.rs.

The files are unchanged except that deckcraft_color:: is crate::color:: and
Fill and TableBorder allow clippy::large_enum_variant. Their 60 tests come
with them. Readers and writers follow.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 18:51:44 -05:00
1 parent 046a927171
commit 5ebc719ca2
11 files changed
+5008 -1

No files matched your search

Generated
+1
View File
@@ -985,6 +985,7 @@ dependencies = [
"craft-xml",
"kurbo 0.13.1",
"serde",
"serde_json",
]
[[package]]
+3
View File
@@ -14,3 +14,6 @@ workspace = true
craft-xml = { workspace = true }
kurbo = { workspace = true }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+14 -1
View File
@@ -16,7 +16,20 @@ files store EMU (12 700 per point).
| `custgeom` | `read_cust_geom` / `write_cust_geom` between `a:custGeom` and `CustomPath` (an `M L C Q Z` path string per sub-path), and `Guides`, the shape guide formula evaluator | `a:custGeom`, `a:gd` |
| `warp` | `warp(name, adjusts, rect)`: the bands and guide curves of a preset text warp, and the mapping of laid-out lines onto them | `a:prstTxWarp` |
Fills, lines, effects, text bodies, tables, themes and charts are not here yet.
## The model
These modules hold types only; their readers and writers are not here yet.
| Module | Types | Markup they stand for |
|---|---|---|
| `style` | `ColorRef` (a base colour plus transforms, resolved against a `ColorScheme`), `Fill`, `Gradient`, `PictureFill`, `Line`, `Effects`, `ShapeStyle` | `a:solidFill`, `a:gradFill`, `a:blipFill`, `a:pattFill`, `a:ln`, `a:effectLst`, `p:style` |
| `text` | `TextBody`, `Paragraph`, `ParaProps`, `Run`, `RunProps`, `ListStyle`, hyperlinks and actions | `a:txBody`, `a:bodyPr`, `a:lstStyle`, `a:p`, `a:r` |
| `table` | `Table`, `Row`, `Cell`, `TableStyleDef` and the built-in table styles | `a:tbl`, `a:tblStyleLst` |
| `chart` | `Chart`, `Series`, axes, data labels, trendlines and error bars, with the values they compute | `c:chartSpace`, `cx:chartSpace` |
| `numfmt` | `format(code, value, lang)`: number format codes as charts and cells use them | `c:numFmt` |
| `diagram` | `Diagram`, `DiagramNode` | `dgm:dataModel` |
A picture or sound is a `MediaId`; whoever owns the package keeps the bytes. Themes are not here yet.
## Preset shapes and text warps
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
//! SmartArt (`dgm:`): a diagram's layout and its outline of nodes.
use serde::{Deserialize, Serialize};
use crate::MediaId;
/// A SmartArt diagram's data: its layout and its outline of nodes (the Text Pane).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Diagram {
/// The layout: list, blockList, hBulletList, process, chevron, verticalProcess, stepUp, cycle,
/// blockCycle, radial, hierarchy, pyramid, invertedPyramid, matrix or titledMatrix.
pub kind: String,
pub nodes: Vec<DiagramNode>,
/// SmartArt Style: `flat` (default), `subtle`, `intense` or `outline`.
#[serde(skip_serializing_if = "String::is_empty")]
pub style: String,
/// Change Colors: `colorful` (default: each shape its own accent) or `accent1`…`accent6`.
#[serde(skip_serializing_if = "String::is_empty")]
pub colors: String,
/// The group's shapes are the file's drawing, not generated from `nodes`: text typed into them
/// isn't mapped back to nodes (the first outline edit regenerates them).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub imported: bool,
/// PowerPoint's layout id (`layoutDef@uniqueId`) the diagram came with.
#[serde(skip_serializing_if = "String::is_empty")]
pub layout_id: String,
/// The file's own layout, quick style and colour definitions, written back unchanged while the
/// layout isn't switched.
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_xml: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub style_xml: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub colors_xml: Option<String>,
}
/// One line of the Text Pane: its text and outline level (0 = top level).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DiagramNode {
pub text: String,
pub level: u8,
/// The picture shown in the node's picture placeholder (picture layouts).
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<MediaId>,
/// An assistant (organisation charts): drawn beside its parent, off the row of its siblings.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub assistant: bool,
/// The job title under the name (Name and Title Organization Chart).
#[serde(skip_serializing_if = "String::is_empty")]
pub title: String,
}
+38
View File
@@ -9,19 +9,57 @@
//! - [`preset`]: outlines of the preset shapes (`a:prstGeom`), by their file names.
//! - [`custgeom`]: custom geometry (`a:custGeom`), with the shape guide formulas.
//! - [`warp`]: preset text warps (`a:prstTxWarp`).
//! - The model, without readers and writers yet: [`style`] (colour references, fills, lines,
//! effects), [`text`] (text bodies, paragraphs, runs), [`table`], [`chart`] with [`numfmt`]
//! (number format codes) and [`diagram`] (SmartArt outlines). Media are named by [`MediaId`];
//! the bytes stay with the caller.
//!
//! Nothing here opens a package: readers take a [`craft_xml::El`] and writers a
//! [`craft_xml::Writer`], so the same code serves a slide, a document or a worksheet drawing.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod chart;
pub mod color;
pub mod custgeom;
pub mod diagram;
pub mod numfmt;
pub mod preset;
pub mod style;
pub mod table;
pub mod text;
mod trendline;
pub mod warp;
use kurbo::{Affine, Point, Rect, Vec2};
use serde::{Deserialize, Serialize};
pub(crate) fn yes() -> bool {
true
}
macro_rules! id_type {
($(#[$doc:meta])* $name:ident) => {
$(#[$doc])*
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(pub u32);
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
};
}
id_type!(
/// A picture, sound or other embedded file, by the id its owner gave it. The bytes stay with
/// the owner (a package part in a file).
MediaId
);
id_type!(
/// The target of a link to a slide. Only presentations have them.
SlideId
);
/// EMU per point.
pub const EMU_PER_PT: f64 = 12_700.0;
/// EMU per inch.
+415
View File
@@ -0,0 +1,415 @@
//! Number formats: Excel format codes (`#,##0`, `0.0%`, `_-* # ##0 "₽"_-;…`, `General`) as chart
//! axes and data labels show them, in the chart's language (digit grouping and decimal mark).
/// Digit grouping and decimal mark for a language tag (`ru-RU`, `de-DE`, `en-US`).
fn marks(lang: Option<&str>) -> (char, char) {
let l = lang.unwrap_or("en").split(['-', '_']).next().unwrap_or("en").to_ascii_lowercase();
match l.as_str() {
// Space-grouped, comma decimal.
"ru" | "uk" | "be" | "kk" | "fr" | "pl" | "cs" | "sk" | "sv" | "fi" | "nb" | "nn" | "no" | "bg" | "lt" | "lv" | "et" | "hu" | "uz" | "hy"
| "ka" | "az" => ('\u{a0}', ','),
// Dot-grouped, comma decimal.
"de" | "es" | "it" | "pt" | "nl" | "da" | "tr" | "el" | "ro" | "id" | "sr" | "hr" | "sl" | "vi" => ('.', ','),
_ => (',', '.'),
}
}
#[derive(Debug, PartialEq)]
enum Tok {
/// Text shown as is.
Lit(String),
/// A digit placeholder: `0`, `#` or `?`.
Digit(char),
Dot,
Comma,
Percent,
}
/// A section's tokens (`[$₽-419]` gives its symbol; colours, conditions and locales drop out).
fn tokens(section: &str) -> Vec<Tok> {
let mut out = vec![];
let mut it = section.chars().peekable();
while let Some(c) = it.next() {
match c {
'"' => {
let mut s = String::new();
for d in it.by_ref() {
if d == '"' {
break;
}
s.push(d);
}
out.push(Tok::Lit(s));
}
'\\' => {
if let Some(d) = it.next() {
out.push(Tok::Lit(d.to_string()));
}
}
// Padding the width of the next character: a space.
'_' => {
it.next();
out.push(Tok::Lit(" ".into()));
}
// Repeat-to-fill: nothing on an axis.
'*' => {
it.next();
}
'[' => {
let mut s = String::new();
for d in it.by_ref() {
if d == ']' {
break;
}
s.push(d);
}
if let Some(sym) = s.strip_prefix('$') {
let sym = sym.split('-').next().unwrap_or("");
if !sym.is_empty() {
out.push(Tok::Lit(sym.to_string()));
}
}
}
'0' | '#' | '?' => out.push(Tok::Digit(c)),
'.' => out.push(Tok::Dot),
',' => out.push(Tok::Comma),
'%' => out.push(Tok::Percent),
'@' => {}
_ => out.push(Tok::Lit(c.to_string())),
}
}
out
}
/// The format's sections split at `;` (outside quotes, brackets and escapes).
fn sections(code: &str) -> Vec<&str> {
let mut out = vec![];
let (mut start, mut quoted, mut bracket, mut escaped) = (0, false, false, false);
for (i, c) in code.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if !quoted => escaped = true,
'"' => quoted = !quoted,
'[' if !quoted => bracket = true,
']' if !quoted => bracket = false,
';' if !quoted && !bracket => {
out.push(code.get(start..i).unwrap_or(""));
start = i + 1;
}
_ => {}
}
}
out.push(code.get(start..).unwrap_or(""));
out
}
/// `v` in the General format: up to 10 significant digits, no grouping.
fn general(v: f64, dec: char) -> String {
if v == 0.0 {
return "0".into();
}
let a = v.abs();
let s = if !(1e-9..1e11).contains(&a) {
let s = format!("{v:.5E}");
match s.split_once('E') {
Some((m, e)) => {
let m = if m.contains('.') { m.trim_end_matches('0').trim_end_matches('.') } else { m };
let e: i32 = e.parse().unwrap_or(0);
format!("{m}E{}{:02}", if e < 0 { '-' } else { '+' }, e.abs())
}
None => s,
}
} else {
let digits = (10 - (a.log10().floor() as i32 + 1)).clamp(0, 10) as usize;
let s = format!("{v:.digits$}");
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
};
s.replace('.', &dec.to_string())
}
/// Group an integer's digits in threes with `sep`.
fn group(digits: &str, sep: char) -> String {
let n = digits.chars().count();
let mut out = String::new();
for (i, d) in digits.chars().enumerate() {
if i > 0 && (n - i).is_multiple_of(3) {
out.push(sep);
}
out.push(d);
}
out
}
/// Longest number format applied, as in Excel (255 characters); a longer one formats as
/// General, so a chart cannot make every label expand a huge format.
pub const MAX_CODE_LEN: usize = 255;
/// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text
/// section of a format doesn't apply).
pub fn format(code: &str, v: f64, lang: Option<&str>) -> String {
let code = if code.len() > MAX_CODE_LEN * 4 || code.chars().count() > MAX_CODE_LEN { "General" } else { code };
let (sep, dec) = marks(lang);
if !v.is_finite() {
return String::new();
}
let secs = sections(code);
// The section for the value's sign; a negative or zero section shows the value unsigned.
let (sec, v, minus) = match (secs.as_slice(), v) {
([_, neg, ..], v) if v < 0.0 => (*neg, -v, false),
([_, _, zero, ..], v) if v == 0.0 => (*zero, v, false),
([pos, ..], v) => (*pos, v.abs(), v < 0.0),
([], v) => ("General", v.abs(), v < 0.0),
};
if sec.trim().eq_ignore_ascii_case("general") || (secs.len() == 1 && sec.trim().is_empty()) {
let s = general(v, dec);
return if minus { format!("-{s}") } else { s };
}
let toks = tokens(sec);
let first = toks.iter().position(|t| matches!(t, Tok::Digit(_)));
let last = toks.iter().rposition(|t| matches!(t, Tok::Digit(_)));
let (Some(first), Some(last)) = (first, last) else {
// No digits: just the section's text ("-" for zero, a label).
let s: String = toks.iter().filter_map(|t| if let Tok::Lit(s) = t { Some(s.as_str()) } else { None }).collect();
return s.trim().to_string();
};
let out = sci(&toks, v, sep, dec).unwrap_or_else(|| fixed(&toks, first, last, v, sep, dec));
let out = out.trim();
if minus && !out.is_empty() { format!("-{out}") } else { out.to_string() }
}
/// The text of the literal and percent tokens in `ts`.
fn lits(ts: &[Tok]) -> String {
ts.iter()
.filter_map(|t| match t {
Tok::Lit(s) => Some(s.clone()),
Tok::Percent => Some("%".into()),
_ => None,
})
.collect()
}
/// Scientific notation (`0.00E+00`, `##0.0E-0`): the mantissa takes the placeholders before the `E`,
/// the exponent's sign and minimum width come from what follows it; with several integer
/// placeholders the exponent is a multiple of their count (engineering notation). `None` if the
/// section has no exponent part.
fn sci(toks: &[Tok], v: f64, sep: char, dec: char) -> Option<String> {
let e = toks.iter().position(|t| matches!(t, Tok::Lit(s) if s.as_str() == "E" || s.as_str() == "e"))?;
let e_char = if let Some(Tok::Lit(s)) = toks.get(e) { s.as_str() } else { "E" };
let plus = match toks.get(e + 1)? {
Tok::Lit(s) if s.as_str() == "+" => true,
Tok::Lit(s) if s.as_str() == "-" => false,
_ => return None,
};
let mant = toks.get(..e)?;
let mfirst = mant.iter().position(|t| matches!(t, Tok::Digit(_)))?;
let mlast = mant.iter().rposition(|t| matches!(t, Tok::Digit(_)))?;
let exp_toks = toks.get(e + 2..)?;
let exp_len = exp_toks.iter().take_while(|t| matches!(t, Tok::Digit(_))).count();
if exp_len == 0 {
return None;
}
let width = exp_toks.iter().take(exp_len).filter(|t| **t == Tok::Digit('0')).count();
let rest = exp_toks.get(exp_len..).unwrap_or(&[]);
let mant_frac = mant.iter().skip_while(|t| **t != Tok::Dot).filter(|t| matches!(t, Tok::Digit(_))).count();
let (mut m, mut exp) = (0.0, 0i32);
if v != 0.0 {
exp = v.log10().floor() as i32;
m = if exp < -300 {
// 10^exp underflows for subnormals; scale in two steps.
v * 1e300 / 10f64.powi(exp + 300)
} else {
v / 10f64.powi(exp)
};
if !m.is_finite() {
m = 0.0;
exp = 0;
}
if m >= 10.0 {
m /= 10.0;
exp += 1;
} else if m < 1.0 {
m *= 10.0;
exp -= 1;
}
// Rounding the mantissa to its digits can reach 10 (9.999 as `0.00`).
if format!("{m:.mant_frac$}").starts_with("10") {
m /= 10.0;
exp += 1;
}
}
let step = mant.iter().take_while(|t| **t != Tok::Dot).filter(|t| matches!(t, Tok::Digit(_))).count().max(1) as i32;
let shift = exp.rem_euclid(step);
exp -= shift;
m *= 10f64.powi(shift);
let mantissa = fixed(mant, mfirst, mlast, m, sep, dec);
let sign = if exp < 0 {
"-"
} else if plus {
"+"
} else {
""
};
let digits = exp.unsigned_abs().to_string();
let pad = "0".repeat(width.saturating_sub(digits.len()));
Some(format!("{mantissa}{e_char}{sign}{pad}{digits}{}", lits(rest)))
}
/// `v` (not negative) in the fixed-point format whose tokens are `toks`, with its digits from
/// `first` to `last`.
fn fixed(toks: &[Tok], first: usize, last: usize, v: f64, sep: char, dec: char) -> String {
let percent = toks.contains(&Tok::Percent);
let number = toks.get(first..=last).unwrap_or(&[]);
let dot = number.iter().position(|t| *t == Tok::Dot);
let (int_part, frac_part) = match dot {
Some(d) => (number.get(..d).unwrap_or(&[]), number.get(d + 1..).unwrap_or(&[])),
None => (number, &[][..]),
};
// Grouping: a comma (or the locale's space, written literally) between integer digits.
let grouping = int_part.iter().any(|t| *t == Tok::Comma || matches!(t, Tok::Lit(s) if s.trim().is_empty()));
// Commas right after the last digit scale by a thousand each (`#,##0,` = thousands).
let after = toks.get(last + 1..).unwrap_or(&[]);
let scale_commas = after.iter().take_while(|t| **t == Tok::Comma).count();
let min_int = int_part.iter().filter(|t| **t == Tok::Digit('0')).count();
let frac_digits: Vec<char> = frac_part.iter().filter_map(|t| if let Tok::Digit(c) = t { Some(*c) } else { None }).collect();
let max_frac = frac_digits.len();
let min_frac = frac_digits.iter().filter(|c| **c == '0').count();
let mut x = v * if percent { 100.0 } else { 1.0 } / 1000f64.powi(scale_commas as i32);
if !x.is_finite() {
x = 0.0;
}
let s = format!("{x:.max_frac$}");
let (ip, fp) = s.split_once('.').unwrap_or((s.as_str(), ""));
let mut fp = fp.to_string();
while fp.len() > min_frac && fp.ends_with('0') {
fp.pop();
}
let ip = ip.trim_start_matches('0');
let ip = if ip.len() < min_int { format!("{}{ip}", "0".repeat(min_int - ip.len())) } else { ip.to_string() };
let mut num = if grouping { group(&ip, sep) } else { ip };
if !fp.is_empty() {
num.push(dec);
num.push_str(&fp);
}
let before = lits(toks.get(..first).unwrap_or(&[]));
let after = lits(toks.get(last + 1..).unwrap_or(&[]));
format!("{before}{num}{after}")
}
#[cfg(test)]
mod tests {
use super::*;
const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#;
#[test]
fn an_overlong_format_is_general() {
let code = "0".repeat(100_000);
assert_eq!(format(&code, 12.5, None), format("General", 12.5, None));
let ok = "0".repeat(MAX_CODE_LEN);
assert_eq!(format(&ok, 7.0, None).len(), MAX_CODE_LEN);
}
#[test]
fn scientific_subnormal() {
assert_eq!(format("0.00E+00", 5e-324, None), "4.94E-324");
assert_eq!(format("0.00E+00", f64::MIN_POSITIVE, None), "2.23E-308");
}
#[test]
fn russian_accounting_roubles() {
assert_eq!(format(RUB, 800_000.0, Some("ru-RU")), "800\u{a0}000 ₽");
assert_eq!(format(RUB, 1_234_567.0, Some("ru-RU")), "1\u{a0}234\u{a0}567 ₽");
assert_eq!(format(RUB, -5_000.0, Some("ru-RU")), "-5\u{a0}000 ₽");
assert_eq!(format(RUB, 0.0, Some("ru-RU")), "- ₽");
}
#[test]
fn common_codes() {
assert_eq!(format("#,##0", 7000.0, Some("ru-RU")), "7\u{a0}000");
assert_eq!(format("#,##0", 7000.0, Some("en-US")), "7,000");
assert_eq!(format("#,##0", 7000.0, Some("de-DE")), "7.000");
assert_eq!(format("0.0%", 0.125, Some("ru-RU")), "12,5%");
assert_eq!(format("0.0%", 0.125, None), "12.5%");
assert_eq!(format("0%", 0.0, None), "0%");
assert_eq!(format("General", 2.5, Some("ru-RU")), "2,5");
assert_eq!(format("General", 400_000.0, Some("ru-RU")), "400000");
assert_eq!(format("General", 1.5e12, None), "1.5E+12");
assert_eq!(format("General", 1e-10, None), "1E-10");
assert_eq!(format("General", -2.5e-12, None), "-2.5E-12");
assert_eq!(format("General", 1e100, None), "1E+100");
assert_eq!(format("0.00", 1.23456, None), "1.23");
assert_eq!(format("#,##0.0#", 1234.5, None), "1,234.5");
assert_eq!(format("#,##0,", 12_345_678.0, None), "12,346");
assert_eq!(format("[$₽-419]#,##0", 1500.0, Some("ru-RU")), "₽1\u{a0}500");
assert_eq!(format("0;[Red]-0", -3.0, None), "-3");
assert_eq!(format("0;0", -3.0, None), "3");
assert_eq!(format("0", -3.0, None), "-3");
assert_eq!(format("\"x\"0", 5.0, None), "x5");
}
#[test]
fn trailing_commas_with_decimals() {
assert_eq!(format("0.0,,", 15_000_000.0, Some("en-US")), "15.0");
assert_eq!(format("0.00,\"K\"", 15_000.0, Some("en-US")), "15.00K");
}
#[test]
fn explicit_empty_sections_hide_their_values() {
for v in [3.0, -3.0, 0.0, -0.0] {
assert_eq!(format(";;;", v, None), "", "all sections empty: {v}");
}
for (code, positive, negative, zero) in [
(";0;0", "", "3", "0"),
("0;;0", "3", "", "0"),
("0;-0;", "3", "-3", ""),
(";0", "", "3", ""),
(r#"";"0;;"#, ";3", "", ""),
(r"\;0;;", ";3", "", ""),
] {
assert_eq!(format(code, 3.0, None), positive, "positive: {code}");
assert_eq!(format(code, -3.0, None), negative, "negative: {code}");
assert_eq!(format(code, 0.0, None), zero, "zero: {code}");
}
assert_eq!(format("0;-0;", -0.0, None), "");
}
#[test]
fn blank_whole_codes_and_general_keep_their_fallback() {
for code in ["", " \t", "General", " general "] {
assert_eq!(format(code, 2.5, None), "2.5", "{code:?}");
assert_eq!(format(code, -2.5, None), "-2.5", "{code:?}");
assert_eq!(format(code, 0.0, None), "0", "{code:?}");
assert_eq!(format(code, 2.5, Some("de-DE")), "2,5", "{code:?}");
}
}
#[test]
fn scientific_formats() {
assert_eq!(format("0.00E+00", 1234.0, None), "1.23E+03");
assert_eq!(format("0.00E+00", 1234.0, Some("ru-RU")), "1,23E+03");
assert_eq!(format("0.00E+00", 0.00012, None), "1.20E-04");
assert_eq!(format("0.00E+00", -1234.0, None), "-1.23E+03");
assert_eq!(format("0.00E+00", 0.0, None), "0.00E+00");
assert_eq!(format("0.0E+000", 5e10, None), "5.0E+010");
assert_eq!(format("0.0E-0", 1234.0, None), "1.2E3");
assert_eq!(format("0.0E-0", 0.0012, None), "1.2E-3");
assert_eq!(format("0E+00", 9999.0, None), "1E+04");
assert_eq!(format("0.00E+00", 9.999, None), "1.00E+01");
assert_eq!(format("0.00e+00", 1234.0, None), "1.23e+03");
assert_eq!(format("##0.0E+0", 12_345.0, None), "12.3E+3");
assert_eq!(format("##0.0E+0", 0.012, None), "12.0E-3");
assert_eq!(format("##0.0E+0", 999.96, None), "1.0E+3");
}
#[test]
fn never_panics() {
for code in ["", ";", ";;;", "\"", "[", "\\", "0.", ".", "#,", "%", "_", "*", "@", "0E+00", "[$", "\"unterminated"] {
for v in [0.0, -1.5, 1e300, f64::NAN, f64::INFINITY, 1e-300] {
let _ = format(code, v, Some("ru"));
}
}
}
}
+572
View File
@@ -0,0 +1,572 @@
//! Colours, fills, lines and effects.
use crate::color::{ColorScheme, ColorTransform, Rgba, SchemeSlot};
use serde::{Deserialize, Serialize};
use crate::MediaId;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum ColorBase {
Rgb {
rgb: Rgba,
},
Scheme {
slot: SchemeSlot,
},
Preset {
name: String,
},
/// A system colour (`windowText`…) with the value it had when saved.
System {
name: String,
last: Rgba,
},
}
/// A colour as written in a document: a base (RGB, theme slot…) plus transforms.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ColorRef {
pub base: ColorBase,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mods: Vec<ColorTransform>,
}
impl ColorRef {
pub fn rgb(c: Rgba) -> Self {
ColorRef { base: ColorBase::Rgb { rgb: c }, mods: vec![] }
}
pub fn scheme(slot: SchemeSlot) -> Self {
ColorRef { base: ColorBase::Scheme { slot }, mods: vec![] }
}
pub fn with(mut self, m: ColorTransform) -> Self {
self.mods.push(m);
self
}
/// Resolve against a colour scheme; `ph` substitutes `phClr` (style matrix colour).
pub fn resolve(&self, scheme: &ColorScheme, ph: Option<Rgba>) -> Rgba {
let base = match &self.base {
ColorBase::Rgb { rgb } => *rgb,
ColorBase::Scheme { slot: SchemeSlot::PhClr } => ph.unwrap_or_else(|| scheme.get(SchemeSlot::Accent1)),
ColorBase::Scheme { slot } => scheme.get(*slot),
ColorBase::Preset { name } => crate::color::preset(name).unwrap_or(Rgba::BLACK),
ColorBase::System { last, .. } => *last,
};
crate::color::apply(base, &self.mods)
}
/// Alpha (0–1) from the `alpha`, `alphaMod` and `alphaOff` transforms, applied in order.
pub fn alpha(&self) -> f64 {
self.mods.iter().fold(1.0, |a, m| match m {
ColorTransform::Alpha(v) => (*v as f64 / 100_000.0).clamp(0.0, 1.0),
ColorTransform::AlphaMod(v) => (a * *v as f64 / 100_000.0).clamp(0.0, 1.0),
ColorTransform::AlphaOff(v) => (a + *v as f64 / 100_000.0).clamp(0.0, 1.0),
_ => a,
})
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GradientStop {
/// 0–1.
pub pos: f64,
pub color: ColorRef,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GradientFlip {
None,
X,
Y,
Xy,
}
impl GradientFlip {
pub fn xml(self) -> &'static str {
match self {
GradientFlip::None => "none",
GradientFlip::X => "x",
GradientFlip::Y => "y",
GradientFlip::Xy => "xy",
}
}
pub fn from_xml(value: &str) -> Option<Self> {
match value {
"none" => Some(GradientFlip::None),
"x" => Some(GradientFlip::X),
"y" => Some(GradientFlip::Y),
"xy" => Some(GradientFlip::Xy),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum GradientShape {
/// Angle in degrees clockwise from left→right.
Linear {
angle: f64,
#[serde(default)]
scaled: bool,
},
/// `circle`, `rect` or `shape` path gradient; `focus` is the fill-to rect as fractions (l, t, r, b).
Path { path: String, focus: [f64; 4] },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Gradient {
pub stops: Vec<GradientStop>,
pub shape: GradientShape,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flip: Option<GradientFlip>,
#[serde(default = "crate::yes")]
pub rotate_with_shape: bool,
/// `a:tileRect`: the box the gradient is laid out in, as insets from the shape's box
/// (fractions; negative reaches outside it). PowerPoint's "from corner" radial presets use it
/// to centre the gradient on the corner.
#[serde(default, skip_serializing_if = "is_zero4")]
pub tile: [f64; 4],
}
fn is_zero4(v: &[f64; 4]) -> bool {
v.iter().all(|x| *x == 0.0)
}
/// How a picture fills its box: stretched into a (fractional) inset rect, or tiled.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum PictureMode {
Stretch {
#[serde(default)]
fill_rect: [f64; 4],
},
Tile {
tx: f64,
ty: f64,
sx: f64,
sy: f64,
#[serde(default)]
flip: String,
#[serde(default)]
align: String,
},
}
impl Default for PictureMode {
fn default() -> Self {
PictureMode::Stretch { fill_rect: [0.0; 4] }
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PictureFill {
pub media: MediaId,
/// Source crop as fractions of the image (left, top, right, bottom); negative values pad.
pub crop: [f64; 4],
pub mode: PictureMode,
/// 0–1 opacity of the picture.
pub alpha: Option<f64>,
pub adjust: PictureAdjust,
/// `rotWithShape="0"`: the picture keeps to the slide's axes when its shape is rotated or
/// flipped.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub upright: bool,
/// An artistic effect (Picture Format ▸ Artistic Effects). `media` is then the processed
/// picture; the original is kept to change or remove the effect.
#[serde(skip_serializing_if = "Option::is_none")]
pub artistic: Option<Artistic>,
}
/// An applied artistic effect: its `a14` name (e.g. `artisticBlur`) and the original picture.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Artistic {
pub effect: String,
pub original: MediaId,
}
/// Picture corrections and colour (Picture Format ▸ Corrections / Color / Transparency).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PictureAdjust {
/// -1..1
pub brightness: f64,
/// -1..1
pub contrast: f64,
/// 0..4 (1 = unchanged)
pub saturation: Option<f64>,
pub grayscale: bool,
/// Recolour duotone (dark, light).
pub duotone: Option<(ColorRef, ColorRef)>,
/// Make a colour transparent (Set Transparent Color).
pub clear_color: Option<Rgba>,
pub sharpen: f64,
/// `a:biLevel`: black and white, split at this luminance (0–1).
#[serde(skip_serializing_if = "Option::is_none")]
pub bilevel: Option<f64>,
/// `a:alphaInv`: opacity inverted.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub alpha_inv: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PatternFill {
pub preset: String,
pub fg: ColorRef,
pub bg: ColorRef,
}
// Boxing the large variant would change every match in the apps' readers and writers.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Fill {
None,
Solid {
color: ColorRef,
},
Gradient(Gradient),
Picture(PictureFill),
Pattern(PatternFill),
/// Use the containing group's fill.
Group,
/// Use the slide background (`useBgFill`).
Background,
}
impl Fill {
pub fn solid(c: ColorRef) -> Self {
Fill::Solid { color: c }
}
pub fn is_none(&self) -> bool {
matches!(self, Fill::None)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LineCap {
#[default]
Flat,
Round,
Square,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LineJoin {
#[default]
Round,
Bevel,
Miter,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LineAlign {
#[default]
Center,
Inside,
}
impl LineAlign {
pub fn xml(self) -> &'static str {
match self {
LineAlign::Center => "ctr",
LineAlign::Inside => "in",
}
}
pub fn from_xml(value: &str) -> Option<Self> {
match value {
"ctr" => Some(LineAlign::Center),
"in" => Some(LineAlign::Inside),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Compound {
#[default]
Single,
Double,
ThickThin,
ThinThick,
Triple,
}
impl Compound {
pub fn xml(self) -> &'static str {
match self {
Compound::Single => "sng",
Compound::Double => "dbl",
Compound::ThickThin => "thickThin",
Compound::ThinThick => "thinThick",
Compound::Triple => "tri",
}
}
pub fn from_xml(s: &str) -> Self {
match s {
"dbl" => Compound::Double,
"thickThin" => Compound::ThickThin,
"thinThick" => Compound::ThinThick,
"tri" => Compound::Triple,
_ => Compound::Single,
}
}
}
/// Dash style: a preset name (`solid`, `dot`, `dash`, `lgDash`, `dashDot`, `lgDashDot`,
/// `lgDashDotDot`, `sysDash`, `sysDot`, `sysDashDot`, `sysDashDotDot`) or custom (dash, space) pairs
/// in multiples of the line width.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Dash {
Preset { name: String },
Custom { pattern: Vec<(f64, f64)> },
}
impl Dash {
pub const PRESETS: [&str; 11] =
["solid", "sysDot", "sysDash", "dash", "dashDot", "lgDash", "lgDashDot", "lgDashDotDot", "dot", "sysDashDot", "sysDashDotDot"];
/// Dash/space lengths in multiples of the line width.
pub fn pattern(&self) -> Vec<f64> {
match self {
Dash::Custom { pattern } => pattern.iter().flat_map(|(d, s)| [*d, *s]).collect(),
Dash::Preset { name } => match name.as_str() {
"dot" => vec![1.0, 3.0],
"dash" => vec![4.0, 3.0],
"lgDash" => vec![8.0, 3.0],
"dashDot" => vec![4.0, 3.0, 1.0, 3.0],
"lgDashDot" => vec![8.0, 3.0, 1.0, 3.0],
"lgDashDotDot" => vec![8.0, 3.0, 1.0, 3.0, 1.0, 3.0],
"sysDash" => vec![3.0, 1.0],
"sysDot" => vec![1.0, 1.0],
"sysDashDot" => vec![3.0, 1.0, 1.0, 1.0],
"sysDashDotDot" => vec![3.0, 1.0, 1.0, 1.0, 1.0, 1.0],
_ => vec![],
},
}
}
}
/// Arrowhead at a line end.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LineEnd {
/// `none`, `triangle`, `stealth`, `diamond`, `oval`, `arrow`.
pub kind: String,
/// `sm`, `med`, `lg`.
#[serde(default = "med")]
pub w: String,
#[serde(default = "med")]
pub len: String,
}
fn med() -> String {
"med".into()
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Line {
pub fill: Option<Fill>,
/// Width in points.
pub width: Option<f64>,
pub dash: Option<Dash>,
pub cap: Option<LineCap>,
pub join: Option<LineJoin>,
pub miter_limit: Option<u32>,
pub align: Option<LineAlign>,
pub compound: Option<Compound>,
pub head: Option<LineEnd>,
pub tail: Option<LineEnd>,
}
impl Line {
pub fn solid(c: ColorRef, width: f64) -> Self {
Line { fill: Some(Fill::solid(c)), width: Some(width), ..Default::default() }
}
pub fn none() -> Self {
Line { fill: Some(Fill::None), ..Default::default() }
}
/// Fill unset fields from `parent`.
pub fn inherit(&mut self, parent: &Line) {
if self.fill.is_none() {
self.fill.clone_from(&parent.fill);
}
self.width = self.width.or(parent.width);
if self.dash.is_none() {
self.dash.clone_from(&parent.dash);
}
self.cap = self.cap.or(parent.cap);
self.join = self.join.or(parent.join);
self.miter_limit = self.miter_limit.or(parent.miter_limit);
self.align = self.align.or(parent.align);
self.compound = self.compound.or(parent.compound);
if self.head.is_none() {
self.head.clone_from(&parent.head);
}
if self.tail.is_none() {
self.tail.clone_from(&parent.tail);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Shadow {
pub color: ColorRef,
/// Blur radius, points.
pub blur: f64,
/// Distance, points.
pub dist: f64,
/// Direction, degrees (0 = right, 90 = down).
pub dir: f64,
#[serde(default)]
pub inner: bool,
/// Scale (1 = 100%) for perspective shadows.
#[serde(default = "one")]
pub sx: f64,
#[serde(default = "one")]
pub sy: f64,
#[serde(default)]
pub kx: f64,
#[serde(default)]
pub ky: f64,
#[serde(default)]
pub align: String,
#[serde(default = "crate::yes")]
pub rotate_with_shape: bool,
}
fn one() -> f64 {
1.0
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Glow {
pub color: ColorRef,
pub radius: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Reflection {
pub blur: f64,
pub start_alpha: f64,
pub end_alpha: f64,
pub end_pos: f64,
pub dist: f64,
/// DrawingML reflection transform values retain their native integer units.
pub st_pos: Option<i32>,
pub dir: Option<i32>,
pub fade_dir: Option<i32>,
pub sx: Option<i32>,
pub sy: Option<i32>,
pub kx: Option<i32>,
pub ky: Option<i32>,
pub align: Option<String>,
pub rotate_with_shape: Option<bool>,
}
impl Default for Reflection {
fn default() -> Self {
Self {
blur: 0.0,
start_alpha: 0.0,
end_alpha: 0.0,
end_pos: 0.0,
dist: 0.0,
st_pos: None,
dir: Some(5_400_000),
fade_dir: None,
sx: None,
sy: Some(-100_000),
kx: None,
ky: None,
align: Some("bl".into()),
rotate_with_shape: Some(false),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Effects {
pub outer_shadow: Option<Shadow>,
pub inner_shadow: Option<Shadow>,
pub glow: Option<Glow>,
/// Soft edges radius in points.
pub soft_edge: Option<f64>,
pub reflection: Option<Reflection>,
/// Bevel preset name for 3-D format (rendered as an approximation).
pub bevel: Option<String>,
/// Unmodelled 3-D XML kept for round-trip (`scene3d`, `sp3d`).
pub raw3d: Option<String>,
/// `a:fillOverlay`: a fill blended over the shape's fill (`blend`: `over`, `mult`, `screen`,
/// `darken`, `lighten`). Only solid and gradient overlays are kept.
pub fill_overlay: Option<FillOverlay>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FillOverlay {
pub blend: String,
pub fill: Fill,
}
impl Effects {
pub fn is_empty(&self) -> bool {
self.outer_shadow.is_none()
&& self.inner_shadow.is_none()
&& self.glow.is_none()
&& self.soft_edge.is_none()
&& self.reflection.is_none()
&& self.bevel.is_none()
&& self.fill_overlay.is_none()
}
}
/// A shape's theme style references (`p:style`): which fill/line/effect of the theme's format scheme
/// to use, with which colour, and the theme font and colour for its text.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShapeStyle {
pub line_ref: (u32, ColorRef),
pub fill_ref: (u32, ColorRef),
pub effect_ref: (u32, ColorRef),
/// `major`, `minor` or `none`, with the text colour.
pub font_ref: (String, Option<ColorRef>),
}
impl ShapeStyle {
/// The default for newly drawn shapes: accent 1 fill, darker accent 1 outline, light text.
pub fn accent(slot: SchemeSlot) -> Self {
ShapeStyle {
line_ref: (2, ColorRef::scheme(slot).with(ColorTransform::Shade(50000))),
fill_ref: (1, ColorRef::scheme(slot)),
effect_ref: (0, ColorRef::scheme(slot)),
font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Lt1))),
}
}
/// The default for lines/connectors.
pub fn line(slot: SchemeSlot) -> Self {
ShapeStyle {
line_ref: (1, ColorRef::scheme(slot)),
fill_ref: (0, ColorRef::scheme(slot)),
effect_ref: (0, ColorRef::scheme(slot)),
font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Tx1))),
}
}
}
+649
View File
@@ -0,0 +1,649 @@
//! Tables (graphic frames with `a:tbl`).
use serde::{Deserialize, Serialize};
use crate::style::{ColorRef, Fill, Line};
use crate::text::{Anchor, TextBody};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Cell {
pub text: TextBody,
pub fill: Option<Fill>,
/// Left, right, top, bottom borders; `None` = from the table style.
pub borders: [Option<Line>; 4],
pub diag_down: Option<Line>,
pub diag_up: Option<Line>,
/// Margins (l, r, t, b) in points; `None` = defaults (7.2, 7.2, 3.6, 3.6).
pub margins: Option<[f64; 4]>,
pub anchor: Option<Anchor>,
pub grid_span: u32,
pub row_span: u32,
/// Covered by a merge to the left / above.
pub h_merge: bool,
pub v_merge: bool,
pub vertical: Option<crate::text::TextDir>,
pub anchor_ctr: Option<bool>,
pub horz_overflow: Option<crate::text::TextOverflow>,
}
impl Cell {
pub fn new(text: &str) -> Self {
Cell { text: TextBody::from_text(text), grid_span: 1, row_span: 1, ..Default::default() }
}
pub fn is_covered(&self) -> bool {
self.h_merge || self.v_merge
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Row {
pub height: f64,
pub cells: Vec<Cell>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Table {
/// Column widths in points.
pub cols: Vec<f64>,
pub rows: Vec<Row>,
/// Table style id (our built-in style names or a PPTX style GUID).
pub style: String,
pub first_row: bool,
pub first_col: bool,
pub last_row: bool,
pub last_col: bool,
pub band_row: bool,
pub band_col: bool,
/// Whether columns are ordered right-to-left (`a:tblPr/@rtl`).
#[serde(skip_serializing_if = "Option::is_none")]
pub rtl: Option<bool>,
/// `a:tblPr` fill: a fill behind the whole table (over the style's own background).
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<Fill>,
}
impl Default for Table {
fn default() -> Self {
Table {
cols: vec![],
rows: vec![],
style: "medium2-accent1".into(),
first_row: true,
first_col: false,
last_row: false,
last_col: false,
band_row: true,
band_col: false,
rtl: None,
background: None,
}
}
}
impl Table {
pub fn new(rows: usize, cols: usize, width: f64, row_height: f64) -> Self {
let rows = rows.clamp(1, 1000);
let cols = cols.clamp(1, 1000);
let cw = if cols > 0 { width / cols as f64 } else { width };
Table {
cols: vec![cw; cols],
rows: (0..rows).map(|_| Row { height: row_height, cells: (0..cols).map(|_| Cell::new("")).collect() }).collect(),
..Default::default()
}
}
pub fn n_rows(&self) -> usize {
self.rows.len()
}
pub fn n_cols(&self) -> usize {
self.cols.len()
}
pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> {
self.rows.get(r).and_then(|row| row.cells.get(c))
}
pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> {
self.rows.get_mut(r).and_then(|row| row.cells.get_mut(c))
}
pub fn width(&self) -> f64 {
self.cols.iter().sum()
}
pub fn height(&self) -> f64 {
self.rows.iter().map(|r| r.height).sum()
}
/// Insert an empty row before `at`. A merge the new row falls inside grows to cover it.
pub fn insert_row(&mut self, at: usize) {
let at = at.min(self.rows.len());
let merges = self.merges();
let h = self.rows.get(at.saturating_sub(1)).map(|r| r.height).unwrap_or(30.0);
self.rows.insert(at, Row { height: h, cells: (0..self.cols.len()).map(|_| Cell::new("")).collect() });
self.set_merges(merges.into_iter().map(|m| m.insert_row(at)));
}
/// Insert an empty column before `at`. A merge the new column falls inside grows to cover it.
pub fn insert_col(&mut self, at: usize) {
let at = at.min(self.cols.len());
let merges = self.merges();
let w = self.cols.get(at.saturating_sub(1)).copied().unwrap_or(80.0);
self.cols.insert(at, w);
for r in &mut self.rows {
let i = at.min(r.cells.len());
r.cells.insert(i, Cell::new(""));
}
self.set_merges(merges.into_iter().map(|m| m.insert_col(at)));
}
/// Delete row `at`. Merges through it shrink; a merge anchored on it moves its anchor (and
/// text) to the next row it covers.
pub fn delete_row(&mut self, at: usize) -> bool {
if self.rows.len() <= 1 || at >= self.rows.len() {
return false;
}
let merges = self.merges();
for m in &merges {
if m.r == at && m.rs > 1 {
self.move_anchor((m.r, m.c), (m.r + 1, m.c));
}
}
self.rows.remove(at);
self.set_merges(merges.into_iter().filter_map(|m| m.delete_row(at)));
true
}
/// Delete column `at`. Merges through it shrink; a merge anchored on it moves its anchor (and
/// text) to the next column it covers.
pub fn delete_col(&mut self, at: usize) -> bool {
if self.cols.len() <= 1 || at >= self.cols.len() {
return false;
}
let merges = self.merges();
for m in &merges {
if m.c == at && m.gs > 1 {
self.move_anchor((m.r, m.c), (m.r, m.c + 1));
}
}
self.cols.remove(at);
for r in &mut self.rows {
if at < r.cells.len() {
r.cells.remove(at);
}
}
self.set_merges(merges.into_iter().filter_map(|m| m.delete_col(at)));
true
}
/// The merged areas: every uncovered cell spanning more than one cell, clipped to the grid.
fn merges(&self) -> Vec<Merge> {
let (nr, nc) = (self.rows.len(), self.cols.len());
let mut out = vec![];
for (r, row) in self.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate().take(nc) {
if cell.is_covered() {
continue;
}
let rs = (cell.row_span.max(1) as usize).min(nr - r);
let gs = (cell.grid_span.max(1) as usize).min(nc - c);
if rs > 1 || gs > 1 {
out.push(Merge { r, c, rs, gs });
}
}
}
out
}
/// Rebuild every cell's spans and merge flags from `merges`.
fn set_merges(&mut self, merges: impl IntoIterator<Item = Merge>) {
for row in &mut self.rows {
for cell in &mut row.cells {
cell.grid_span = 1;
cell.row_span = 1;
cell.h_merge = false;
cell.v_merge = false;
}
}
for m in merges {
if m.rs <= 1 && m.gs <= 1 {
continue;
}
for r in m.r..m.r.saturating_add(m.rs) {
for c in m.c..m.c.saturating_add(m.gs) {
if let Some(cell) = self.cell_mut(r, c) {
cell.h_merge = c > m.c;
cell.v_merge = r > m.r;
}
}
}
if let Some(cell) = self.cell_mut(m.r, m.c) {
cell.grid_span = u32::try_from(m.gs).unwrap_or(u32::MAX);
cell.row_span = u32::try_from(m.rs).unwrap_or(u32::MAX);
}
}
}
/// Swap the anchor cell's contents into the cell that takes over as anchor.
fn move_anchor(&mut self, from: (usize, usize), to: (usize, usize)) {
let Some(a) = self.cell(from.0, from.1).cloned() else { return };
let Some(b) = self.cell(to.0, to.1).cloned() else { return };
if let Some(x) = self.cell_mut(to.0, to.1) {
*x = a;
}
if let Some(x) = self.cell_mut(from.0, from.1) {
*x = b;
}
}
/// Merge the rectangle (r0,c0)–(r1,c1) inclusive into its top-left cell.
pub fn merge(&mut self, r0: usize, c0: usize, r1: usize, c1: usize) -> bool {
let (r0, r1) = (r0.min(r1), r0.max(r1));
let (c0, c1) = (c0.min(c1), c0.max(c1));
if r1 >= self.rows.len() || c1 >= self.cols.len() || (r0 == r1 && c0 == c1) {
return false;
}
// Grow the rectangle until no existing merge sticks out of it.
let (mut r0, mut c0, mut r1, mut c1) = (r0, c0, r1, c1);
loop {
let before = (r0, c0, r1, c1);
for (r, row) in self.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate().take(self.cols.len()) {
if cell.is_covered() {
continue;
}
let ar1 = r.saturating_add((cell.row_span.max(1) as usize).saturating_sub(1)).min(self.rows.len() - 1);
let ac1 = c.saturating_add((cell.grid_span.max(1) as usize).saturating_sub(1)).min(self.cols.len() - 1);
if r <= r1 && ar1 >= r0 && c <= c1 && ac1 >= c0 {
r0 = r0.min(r);
c0 = c0.min(c);
r1 = r1.max(ar1);
c1 = c1.max(ac1);
}
}
}
if (r0, c0, r1, c1) == before {
break;
}
}
let mut text = vec![];
for r in r0..=r1 {
for c in c0..=c1 {
if let Some(cell) = self.cell_mut(r, c) {
if !cell.text.is_empty() {
text.append(&mut cell.text.paragraphs);
}
cell.h_merge = c > c0;
cell.v_merge = r > r0;
cell.grid_span = 1;
cell.row_span = 1;
}
}
}
if let Some(cell) = self.cell_mut(r0, c0) {
cell.grid_span = (c1 - c0 + 1) as u32;
cell.row_span = (r1 - r0 + 1) as u32;
cell.h_merge = false;
cell.v_merge = false;
if !text.is_empty() {
cell.text.paragraphs = text;
}
}
true
}
/// Undo a merge at (r, c).
pub fn split(&mut self, r: usize, c: usize) -> bool {
let Some(cell) = self.cell(r, c) else { return false };
let (gs, rs) = (cell.grid_span.max(1) as usize, cell.row_span.max(1) as usize);
if gs == 1 && rs == 1 {
return false;
}
// Spans come from the file and may reach past the table; only walk the cells that exist.
for rr in r..r.saturating_add(rs).min(self.rows.len()) {
for cc in c..c.saturating_add(gs).min(self.cols.len()) {
if let Some(x) = self.cell_mut(rr, cc) {
x.h_merge = false;
x.v_merge = false;
x.grid_span = 1;
x.row_span = 1;
}
}
}
true
}
/// Make every row have exactly `cols.len()` cells (repair after loading).
pub fn normalize(&mut self) {
let n = self.cols.len();
for r in &mut self.rows {
r.cells.resize_with(n, || Cell::new(""));
for c in &mut r.cells {
c.grid_span = c.grid_span.max(1);
c.row_span = c.row_span.max(1);
}
}
}
}
/// A merged area: anchor (r, c) spanning `rs` rows and `gs` columns.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Merge {
r: usize,
c: usize,
rs: usize,
gs: usize,
}
impl Merge {
fn insert_row(self, at: usize) -> Self {
if at <= self.r {
Merge { r: self.r + 1, ..self }
} else if at < self.r + self.rs {
Merge { rs: self.rs + 1, ..self }
} else {
self
}
}
fn insert_col(self, at: usize) -> Self {
if at <= self.c {
Merge { c: self.c + 1, ..self }
} else if at < self.c + self.gs {
Merge { gs: self.gs + 1, ..self }
} else {
self
}
}
fn delete_row(self, at: usize) -> Option<Self> {
let m = if at < self.r {
Merge { r: self.r - 1, ..self }
} else if at < self.r + self.rs {
Merge { rs: self.rs - 1, ..self }
} else {
self
};
// A merge that lost its only row or column is gone, not a 0-span merge over its neighbours.
(m.rs >= 1 && m.gs >= 1 && (m.rs > 1 || m.gs > 1)).then_some(m)
}
fn delete_col(self, at: usize) -> Option<Self> {
let m = if at < self.c {
Merge { c: self.c - 1, ..self }
} else if at < self.c + self.gs {
Merge { gs: self.gs - 1, ..self }
} else {
self
};
// A merge that lost its only row or column is gone, not a 0-span merge over its neighbours.
(m.rs >= 1 && m.gs >= 1 && (m.rs > 1 || m.gs > 1)).then_some(m)
}
}
/// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in
/// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and
/// last rows and columns, corner cells) is filled, bordered and lettered.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TableStyleDef {
/// The style's GUID (`a:tableStyleId` on tables).
pub id: String,
pub name: String,
pub parts: Vec<(TablePart, TablePartStyle)>,
/// `a:tblBg`: a fill behind the whole table (only `fill` and `fill_ref` are used).
pub background: Option<TablePartStyle>,
/// The definition as read, written back unchanged.
#[serde(skip_serializing_if = "String::is_empty")]
pub raw: String,
}
impl TableStyleDef {
pub fn part(&self, p: TablePart) -> Option<&TablePartStyle> {
self.parts.iter().find(|(k, _)| *k == p).map(|(_, s)| s)
}
}
/// The parts of a table style, in the order they apply (later ones win).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TablePart {
WholeTbl,
Band1H,
Band2H,
Band1V,
Band2V,
LastCol,
FirstCol,
LastRow,
FirstRow,
SeCell,
SwCell,
NeCell,
NwCell,
}
impl TablePart {
pub const ALL: [TablePart; 13] = [
TablePart::WholeTbl,
TablePart::Band1H,
TablePart::Band2H,
TablePart::Band1V,
TablePart::Band2V,
TablePart::LastCol,
TablePart::FirstCol,
TablePart::LastRow,
TablePart::FirstRow,
TablePart::SeCell,
TablePart::SwCell,
TablePart::NeCell,
TablePart::NwCell,
];
pub fn xml(self) -> &'static str {
match self {
TablePart::WholeTbl => "wholeTbl",
TablePart::Band1H => "band1H",
TablePart::Band2H => "band2H",
TablePart::Band1V => "band1V",
TablePart::Band2V => "band2V",
TablePart::LastCol => "lastCol",
TablePart::FirstCol => "firstCol",
TablePart::LastRow => "lastRow",
TablePart::FirstRow => "firstRow",
TablePart::SeCell => "seCell",
TablePart::SwCell => "swCell",
TablePart::NeCell => "neCell",
TablePart::NwCell => "nwCell",
}
}
pub fn from_xml(s: &str) -> Option<TablePart> {
TablePart::ALL.into_iter().find(|p| p.xml() == s)
}
}
/// One part of a table style. A theme reference (`a:fillRef`, `a:lnRef`) is kept as the theme's
/// style index with its colour.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TablePartStyle {
pub fill: Option<Fill>,
pub fill_ref: Option<(u32, ColorRef)>,
/// Left, right, top, bottom, inside horizontal, inside vertical.
pub borders: [Option<TableBorder>; 6],
pub text_color: Option<ColorRef>,
pub bold: Option<bool>,
pub italic: Option<bool>,
}
/// A border of a table style part: a line, or a theme line style reference.
// Boxing the large variant would change every match in the apps' readers and writers.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TableBorder {
Line { line: Line },
Ref { idx: u32, color: ColorRef },
}
/// Built-in table styles: (id, label). Styles are generated from the theme accents.
pub fn table_styles() -> Vec<(String, String)> {
let mut v = vec![("none".to_string(), "No Style, No Grid".to_string()), ("grid".to_string(), "No Style, Table Grid".to_string())];
for (kind, label) in [
("light1", "Light Style 1"),
("light2", "Light Style 2"),
("medium2", "Medium Style 2"),
("medium4", "Medium Style 4"),
("dark1", "Dark Style 1"),
] {
v.push((format!("{kind}-tx1"), label.to_string()));
for a in 1..=6 {
v.push((format!("{kind}-accent{a}"), format!("{label} - Accent {a}")));
}
}
v
}
#[cfg(test)]
mod tests {
use super::*;
/// Every cell is owned by exactly one anchor (itself or a span over it), and no span leaves the grid.
fn assert_topology(t: &Table) {
let (nr, nc) = (t.n_rows(), t.n_cols());
let mut owner = vec![vec![0u32; nc]; nr];
for (r, row) in t.rows.iter().enumerate() {
assert_eq!(row.cells.len(), nc);
for (c, cell) in row.cells.iter().enumerate() {
if cell.is_covered() {
assert_eq!((cell.grid_span, cell.row_span), (1, 1), "covered ({r},{c}) has spans");
continue;
}
let (rs, gs) = (cell.row_span as usize, cell.grid_span as usize);
assert!(r + rs <= nr && c + gs <= nc, "span at ({r},{c}) leaves the grid");
for o in owner.iter_mut().skip(r).take(rs) {
for x in o.iter_mut().skip(c).take(gs) {
*x += 1;
}
}
}
}
for (r, o) in owner.iter().enumerate() {
for (c, n) in o.iter().enumerate() {
assert_eq!(*n, 1, "cell ({r},{c}) owned {n} times");
}
}
}
fn merged() -> Table {
// 4x4 with a 2x2 merge at (1,1) holding "M".
let mut t = Table::new(4, 4, 400.0, 30.0);
if let Some(c) = t.cell_mut(1, 1) {
c.text = TextBody::from_text("M");
}
assert!(t.merge(1, 1, 2, 2));
assert_topology(&t);
t
}
fn anchor_text(t: &Table, r: usize, c: usize) -> String {
t.cell(r, c).unwrap().text.text()
}
#[test]
fn merging_over_an_existing_merge_takes_it_in() {
let mut t = Table::new(3, 3, 300.0, 30.0);
if let Some(c) = t.cell_mut(1, 2) {
c.text = TextBody::from_text("X");
}
assert!(t.merge(1, 1, 1, 2));
assert!(t.merge(0, 0, 1, 1));
assert_topology(&t);
let a = t.cell(0, 0).unwrap();
assert_eq!((a.row_span, a.grid_span), (2, 3));
assert_eq!(anchor_text(&t, 0, 0), "X");
}
#[test]
fn row_and_column_edits_keep_merges_consistent() {
for at in 0..=4 {
let mut t = merged();
t.insert_row(at);
assert_topology(&t);
let mut t = merged();
t.insert_col(at);
assert_topology(&t);
}
for at in 0..4 {
let mut t = merged();
assert!(t.delete_row(at));
assert_topology(&t);
let mut t = merged();
assert!(t.delete_col(at));
assert_topology(&t);
}
}
#[test]
fn inserting_inside_a_merge_grows_it() {
let mut t = merged();
t.insert_row(2);
let a = t.cell(1, 1).unwrap();
assert_eq!((a.row_span, a.grid_span), (3, 2));
assert!(t.cell(2, 2).unwrap().h_merge && t.cell(2, 2).unwrap().v_merge);
t.insert_col(0);
assert_eq!(t.cell(1, 2).unwrap().grid_span, 2);
assert_eq!(anchor_text(&t, 1, 2), "M");
assert_topology(&t);
}
#[test]
fn deleting_the_anchor_row_or_column_keeps_the_text() {
let mut t = merged();
assert!(t.delete_row(1));
let a = t.cell(1, 1).unwrap();
assert!(!a.is_covered());
assert_eq!((a.row_span, a.grid_span), (1, 2));
assert_eq!(anchor_text(&t, 1, 1), "M");
assert!(t.delete_col(1));
let a = t.cell(1, 1).unwrap();
assert!(!a.is_covered());
assert_eq!((a.row_span, a.grid_span), (1, 1));
assert_eq!(anchor_text(&t, 1, 1), "M");
assert_topology(&t);
}
#[test]
fn deleting_the_only_row_or_column_of_a_merge_removes_it() {
// A 1x3 merge across a row: deleting that row leaves no merge behind.
let mut t = Table::new(3, 3, 300.0, 30.0);
assert!(t.merge(1, 0, 1, 2));
assert!(t.delete_row(1));
assert!((0..t.n_rows()).all(|r| (0..t.n_cols()).all(|c| t.cell(r, c).is_some_and(|x| !x.is_covered() && x.grid_span <= 1))));
assert_topology(&t);
// A 3x1 merge down a column: deleting that column leaves no merge behind.
let mut t = Table::new(3, 3, 300.0, 30.0);
assert!(t.merge(0, 1, 2, 1));
assert!(t.delete_col(1));
assert!((0..t.n_rows()).all(|r| (0..t.n_cols()).all(|c| t.cell(r, c).is_some_and(|x| !x.is_covered() && x.row_span <= 1))));
assert_topology(&t);
}
#[test]
fn insert_delete_merge_split() {
let mut t = Table::new(3, 3, 300.0, 30.0);
assert_eq!((t.n_rows(), t.n_cols()), (3, 3));
t.insert_row(1);
t.insert_col(3);
assert_eq!((t.n_rows(), t.n_cols()), (4, 4));
assert!(t.delete_row(0) && t.delete_col(0));
assert_eq!((t.n_rows(), t.n_cols()), (3, 3));
assert!(t.merge(0, 0, 1, 1));
assert_eq!(t.cell(0, 0).map(|c| (c.grid_span, c.row_span)), Some((2, 2)));
assert!(t.cell(1, 1).is_some_and(|c| c.is_covered()));
assert!(t.split(0, 0));
assert!(!t.cell(1, 1).is_some_and(|c| c.is_covered()));
assert!(!t.merge(5, 5, 6, 6));
assert!(!t.delete_row(99));
let mut one = Table::new(1, 1, 10.0, 10.0);
assert!(!one.delete_row(0) && !one.delete_col(0));
assert!(table_styles().len() > 30);
}
#[test]
fn split_ignores_spans_past_the_table() {
let mut t = Table::new(2, 2, 100.0, 10.0);
if let Some(c) = t.cell_mut(0, 0) {
c.grid_span = u32::MAX;
c.row_span = u32::MAX;
}
assert!(t.split(0, 0));
assert_eq!(t.cell(0, 0).map(|c| (c.grid_span, c.row_span)), Some((1, 1)));
}
}
+819
View File
@@ -0,0 +1,819 @@
//! Text bodies: body properties, paragraphs, runs, list styles.
use serde::{Deserialize, Serialize};
use crate::style::{ColorRef, Fill, Line};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Anchor {
Top,
Middle,
Bottom,
Justified,
Distributed,
}
impl Anchor {
pub fn xml(self) -> &'static str {
match self {
Anchor::Top => "t",
Anchor::Middle => "ctr",
Anchor::Bottom => "b",
Anchor::Justified => "just",
Anchor::Distributed => "dist",
}
}
pub fn from_xml(s: &str) -> Option<Self> {
Some(match s {
"t" => Anchor::Top,
"ctr" => Anchor::Middle,
"b" => Anchor::Bottom,
"just" => Anchor::Justified,
"dist" => Anchor::Distributed,
_ => return None,
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum AutoFit {
/// Do not autofit.
None,
/// Shrink text on overflow: font scale and line-spacing reduction (0–1). `font_scale` is
/// `None` for a file's `<a:normAutofit/>` with no `fontScale`: the schema default 100 %,
/// which PowerPoint draws as it is and only fits again when the text changes (#724).
Shrink {
#[serde(skip_serializing_if = "Option::is_none")]
font_scale: Option<f64>,
line_reduction: f64,
},
/// Resize shape to fit text.
Shape,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextDir {
Horizontal,
Vertical,
Vertical270,
Stacked,
EaVertical,
/// `mongolianVert`: turned like `vert`, lines running from left to right.
MongolianVertical,
/// `wordArtVertRtl`: stacked, lines running from right to left.
StackedRtl,
}
impl TextDir {
pub fn xml(self) -> &'static str {
match self {
TextDir::Horizontal => "horz",
TextDir::Vertical => "vert",
TextDir::Vertical270 => "vert270",
TextDir::Stacked => "wordArtVert",
TextDir::EaVertical => "eaVert",
TextDir::MongolianVertical => "mongolianVert",
TextDir::StackedRtl => "wordArtVertRtl",
}
}
pub fn from_xml(s: &str) -> Option<Self> {
Some(match s {
"horz" => TextDir::Horizontal,
"vert" => TextDir::Vertical,
"mongolianVert" => TextDir::MongolianVertical,
"vert270" => TextDir::Vertical270,
"wordArtVert" => TextDir::Stacked,
"wordArtVertRtl" => TextDir::StackedRtl,
"eaVert" => TextDir::EaVertical,
_ => return None,
})
}
}
/// `bodyPr`: insets in points, anchoring, wrapping, autofit, columns, direction, warp.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct BodyProps {
pub inset_l: Option<f64>,
pub inset_t: Option<f64>,
pub inset_r: Option<f64>,
pub inset_b: Option<f64>,
pub anchor: Option<Anchor>,
pub anchor_ctr: Option<bool>,
pub wrap: Option<bool>,
pub autofit: Option<AutoFit>,
pub vert: Option<TextDir>,
/// Extra text rotation in degrees.
pub rot: Option<f64>,
pub columns: Option<u32>,
pub col_spacing: Option<f64>,
/// WordArt transform (`prstTxWarp`), e.g. `textArchUp`.
pub warp: Option<String>,
/// The warp's adjust values (`prstTxWarp/avLst`: `adj` or `adj1`, `adj2`…, raw guide values);
/// empty = the preset's defaults.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warp_adj: Vec<f64>,
pub rtl_col: Option<bool>,
/// Keep text upright when the shape rotates.
pub upright: Option<bool>,
/// Space before the first paragraph and after the last count too (`spcFirstLastPara`).
pub space_first_last: Option<bool>,
pub compat_ln_spc: Option<bool>,
pub vert_overflow: Option<TextOverflow>,
pub horz_overflow: Option<TextOverflow>,
/// Unmodelled 3-D XML on the text body (`a:scene3d`, `a:sp3d`, `a:flatTx`).
#[serde(skip_serializing_if = "Option::is_none")]
pub raw3d: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextOverflow {
Overflow,
Clip,
Ellipsis,
}
impl TextOverflow {
pub fn xml(self) -> &'static str {
match self {
Self::Overflow => "overflow",
Self::Clip => "clip",
Self::Ellipsis => "ellipsis",
}
}
pub fn from_xml(value: &str) -> Option<Self> {
Some(match value {
"overflow" => Self::Overflow,
"clip" => Self::Clip,
"ellipsis" => Self::Ellipsis,
_ => return None,
})
}
/// A `horzOverflow` value: `ST_TextHorzOverflowType` has only `overflow` and `clip`.
pub fn from_horz_xml(value: &str) -> Option<Self> {
Self::from_xml(value).filter(|v| *v != Self::Ellipsis)
}
/// The `horzOverflow` attribute to write, leaving out `ellipsis`, which is vertical only.
pub fn horz_xml(v: Option<Self>) -> Option<&'static str> {
v.filter(|v| *v != Self::Ellipsis).map(Self::xml)
}
}
impl BodyProps {
pub fn inherit(&mut self, p: &BodyProps) {
self.inset_l = self.inset_l.or(p.inset_l);
self.inset_t = self.inset_t.or(p.inset_t);
self.inset_r = self.inset_r.or(p.inset_r);
self.inset_b = self.inset_b.or(p.inset_b);
self.anchor = self.anchor.or(p.anchor);
self.anchor_ctr = self.anchor_ctr.or(p.anchor_ctr);
self.wrap = self.wrap.or(p.wrap);
self.autofit = self.autofit.or(p.autofit);
self.vert = self.vert.or(p.vert);
self.rot = self.rot.or(p.rot);
self.columns = self.columns.or(p.columns);
self.col_spacing = self.col_spacing.or(p.col_spacing);
if self.warp.is_none() {
self.warp.clone_from(&p.warp);
self.warp_adj.clone_from(&p.warp_adj);
}
self.rtl_col = self.rtl_col.or(p.rtl_col);
self.upright = self.upright.or(p.upright);
self.space_first_last = self.space_first_last.or(p.space_first_last);
self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc);
self.vert_overflow = self.vert_overflow.or(p.vert_overflow);
self.horz_overflow = self.horz_overflow.or(p.horz_overflow);
if self.raw3d.is_none() {
self.raw3d.clone_from(&p.raw3d);
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Align {
Left,
Center,
Right,
Justify,
/// Justified with kashida (tatweel) elongation for Arabic (`justLow`).
JustLow,
Distributed,
ThaiDistributed,
}
impl Align {
pub fn xml(self) -> &'static str {
match self {
Align::Left => "l",
Align::Center => "ctr",
Align::Right => "r",
Align::Justify => "just",
Align::JustLow => "justLow",
Align::Distributed => "dist",
Align::ThaiDistributed => "thaiDist",
}
}
pub fn from_xml(s: &str) -> Option<Self> {
Some(match s {
"l" => Align::Left,
"ctr" => Align::Center,
"r" => Align::Right,
"just" => Align::Justify,
"thaiDist" => Align::ThaiDistributed,
"justLow" => Align::JustLow,
"dist" => Align::Distributed,
_ => return None,
})
}
}
/// Spacing as a percentage of the line (1.0 = single) or in points.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "v")]
pub enum Spacing {
Pct(f64),
Pts(f64),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Bullet {
None,
Char {
char: String,
},
/// `arabicPeriod`, `arabicParenR`, `romanUcPeriod`, `romanLcPeriod`, `alphaUcPeriod`,
/// `alphaLcParenR`, `alphaLcPeriod`, `circleNumDbPlain`…
AutoNum {
scheme: String,
#[serde(default = "start_one")]
start_at: u32,
},
Picture {
media: crate::MediaId,
},
}
fn start_one() -> u32 {
1
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TabStop {
pub pos: f64,
/// `l`, `ctr`, `r`, `dec`.
pub align: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ParaProps {
pub align: Option<Align>,
/// Left margin (points).
pub margin_left: Option<f64>,
pub margin_right: Option<f64>,
/// First-line indent relative to the margin (negative = hanging).
pub indent: Option<f64>,
pub line_spacing: Option<Spacing>,
pub space_before: Option<Spacing>,
pub space_after: Option<Spacing>,
pub bullet: Option<Bullet>,
pub bullet_font: Option<String>,
pub bullet_font_follow_text: Option<bool>,
/// `None` = follow text colour.
pub bullet_color: Option<ColorRef>,
pub bullet_color_follow_text: Option<bool>,
/// Bullet size as a fraction of the text size.
pub bullet_size: Option<f64>,
/// Absolute bullet font size in points, independent of the paragraph's run size.
pub bullet_size_points: Option<f64>,
pub bullet_size_follow_text: Option<bool>,
pub tabs: Option<Vec<TabStop>>,
pub default_tab: Option<f64>,
pub rtl: Option<bool>,
pub font_align: Option<String>,
pub east_asian_line_break: Option<bool>,
/// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default.
pub latin_line_break: Option<bool>,
/// `hangingPunct`: closing East Asian punctuation may hang past the end of a line.
pub hanging_punct: Option<bool>,
}
impl ParaProps {
pub fn inherit(&mut self, p: &ParaProps) {
self.align = self.align.or(p.align);
self.margin_left = self.margin_left.or(p.margin_left);
self.margin_right = self.margin_right.or(p.margin_right);
self.indent = self.indent.or(p.indent);
self.line_spacing = self.line_spacing.or(p.line_spacing);
self.space_before = self.space_before.or(p.space_before);
self.space_after = self.space_after.or(p.space_after);
if self.bullet.is_none() {
self.bullet.clone_from(&p.bullet);
}
if self.bullet_font_follow_text == Some(false) || self.bullet_font.is_some() {
self.bullet_font_follow_text = None;
}
if self.bullet_font.is_none() && self.bullet_font_follow_text.is_none() {
if p.bullet_font_follow_text == Some(true) {
self.bullet_font_follow_text = Some(true);
} else {
self.bullet_font.clone_from(&p.bullet_font);
}
}
if self.bullet_color_follow_text == Some(false) || self.bullet_color.is_some() {
self.bullet_color_follow_text = None;
}
if self.bullet_color.is_none() && self.bullet_color_follow_text.is_none() {
if p.bullet_color_follow_text == Some(true) {
self.bullet_color_follow_text = Some(true);
} else {
self.bullet_color.clone_from(&p.bullet_color);
}
}
if self.bullet_size_follow_text == Some(false) || self.bullet_size.is_some() || self.bullet_size_points.is_some() {
self.bullet_size_follow_text = None;
}
if self.bullet_size.is_none() && self.bullet_size_points.is_none() && self.bullet_size_follow_text.is_none() {
if p.bullet_size_points.is_some() {
self.bullet_size_points = p.bullet_size_points;
} else if p.bullet_size_follow_text == Some(true) {
self.bullet_size_follow_text = Some(true);
} else {
self.bullet_size = p.bullet_size;
}
}
if self.tabs.is_none() {
self.tabs.clone_from(&p.tabs);
}
self.default_tab = self.default_tab.or(p.default_tab);
self.rtl = self.rtl.or(p.rtl);
if self.font_align.is_none() {
self.font_align.clone_from(&p.font_align);
}
self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break);
self.latin_line_break = self.latin_line_break.or(p.latin_line_break);
self.hanging_punct = self.hanging_punct.or(p.hanging_punct);
}
}
#[cfg(test)]
mod bullet_size_tests {
use super::ParaProps;
#[test]
fn bullet_size_inheritance_keeps_percentage_and_points_as_one_mode() {
let parent = ParaProps { bullet_size_points: Some(12.0), ..Default::default() };
let mut child = ParaProps { bullet_size: Some(2.0), ..Default::default() };
child.inherit(&parent);
assert_eq!(child.bullet_size, Some(2.0));
assert_eq!(child.bullet_size_points, None);
let parent = ParaProps { bullet_size: Some(1.5), ..Default::default() };
let mut child = ParaProps { bullet_size_points: Some(14.0), ..Default::default() };
child.inherit(&parent);
assert_eq!(child.bullet_size, None);
assert_eq!(child.bullet_size_points, Some(14.0));
let parent = ParaProps { bullet_size_points: Some(12.0), ..Default::default() };
let mut child = ParaProps { bullet_size_follow_text: Some(true), ..Default::default() };
child.inherit(&parent);
assert_eq!(child.bullet_size, None);
assert_eq!(child.bullet_size_points, None);
assert_eq!(child.bullet_size_follow_text, Some(true));
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Caps {
None,
Small,
All,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Strike {
None,
Single,
Double,
}
/// A hyperlink or action on text or a shape.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Action {
Url {
url: String,
},
Slide {
slide: crate::SlideId,
},
NextSlide,
PreviousSlide,
FirstSlide,
LastSlide,
EndShow,
LastViewed,
/// Run a custom show. `returns` is PowerPoint's "Show and return".
CustomShow {
name: String,
#[serde(default = "custom_show_returns", skip_serializing_if = "is_true")]
returns: bool,
},
Program {
path: String,
},
PlayMedia,
}
/// Can a link from a (possibly untrusted) deck be opened outside it, by the slide show or by
/// whoever opens an exported file? Only web pages and mail: `file:`, `javascript:`, relative paths
/// and other schemes (OS protocol handlers, network shares) are not.
pub fn safe_url(url: &str) -> bool {
let u = url.trim();
let Some((scheme, rest)) = u.split_once(':') else { return false };
match scheme.to_ascii_lowercase().as_str() {
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
"mailto" => !rest.is_empty(),
_ => false,
}
}
fn custom_show_returns() -> bool {
true
}
fn is_true(v: &bool) -> bool {
*v
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Hyperlink {
pub action: Action,
#[serde(default)]
pub tooltip: String,
#[serde(default)]
pub highlight_click: bool,
/// Sound played when the action runs (`a:snd`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sound: Option<crate::MediaId>,
/// `endSnd`: stop the sound that is already playing.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stop_sound: bool,
/// Zoom (Insert ▸ Zoom) with Return to Zoom: in the show, after this slide is shown and
/// advanced past, go back to the slide with the zoom. Saved to .pptx as a plain slide link.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub zoom_return: Option<crate::SlideId>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct RunProps {
/// Latin font family, or a theme reference `+mj-lt` / `+mn-lt`.
pub font: Option<String>,
pub font_ea: Option<String>,
pub font_cs: Option<String>,
pub font_sym: Option<String>,
/// Size in points.
pub size: Option<f64>,
pub bold: Option<bool>,
pub italic: Option<bool>,
/// `none`, `sng`, `dbl`, `heavy`, `dotted`, `dash`, `wavy`…
pub underline: Option<String>,
pub underline_color: Option<ColorRef>,
pub strike: Option<Strike>,
/// Baseline offset as a fraction of the size (0.3 = superscript 30%).
pub baseline: Option<f64>,
pub caps: Option<Caps>,
/// Character spacing in points.
pub spacing: Option<f64>,
/// Kern at and above this size (points); 0 = off.
pub kern: Option<f64>,
pub fill: Option<Fill>,
pub outline: Option<Line>,
pub highlight: Option<ColorRef>,
pub link: Option<Hyperlink>,
/// Action on mouse over (Insert ▸ Action ▸ Mouse Over on selected text, `a:hlinkMouseOver`).
pub hover: Option<Hyperlink>,
pub lang: Option<String>,
#[serde(deserialize_with = "bounded_language")]
pub alt_lang: Option<String>,
pub shadow: Option<crate::style::Shadow>,
/// `a:innerShdw` on the run. Outer shadow stays in `shadow`.
pub inner_shadow: Option<crate::style::Shadow>,
pub glow: Option<crate::style::Glow>,
pub reflection: Option<crate::style::Reflection>,
/// Soft-edge radius in points (`a:softEdge`).
pub soft_edge: Option<f64>,
/// Spelling/grammar errors are not flagged.
pub no_proof: Option<bool>,
pub normalize_height: Option<bool>,
}
fn bounded_language<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer).map(|lang| lang.map(|value| value.chars().take(256).collect()))
}
impl RunProps {
pub fn inherit(&mut self, p: &RunProps) {
macro_rules! take {
($($f:ident),*) => { $( if self.$f.is_none() { self.$f.clone_from(&p.$f); } )* };
}
take!(
font,
font_ea,
font_cs,
font_sym,
size,
bold,
italic,
underline,
underline_color,
strike,
baseline,
caps,
spacing,
kern,
fill,
outline,
highlight,
link,
hover,
lang,
alt_lang,
shadow,
inner_shadow,
glow,
reflection,
soft_edge,
no_proof,
normalize_height
);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum RunKind {
Text,
/// A line break inside the paragraph (Shift+Enter).
Break,
/// A field: `slidenum`, `datetime`, `datetime1`…`datetime13`, or other; `text` holds the last value.
Field {
field: String,
},
/// An inline equation (OMML kept as XML; `text` is its linear form).
Math {
omml: String,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Run {
#[serde(default)]
pub text: String,
#[serde(default, skip_serializing_if = "is_default_props")]
pub props: RunProps,
#[serde(default = "text_kind", skip_serializing_if = "is_text_kind")]
pub kind: RunKind,
}
fn is_default_props(p: &RunProps) -> bool {
*p == RunProps::default()
}
fn text_kind() -> RunKind {
RunKind::Text
}
fn is_text_kind(k: &RunKind) -> bool {
*k == RunKind::Text
}
impl Run {
pub fn new(text: impl Into<String>) -> Self {
Run { text: text.into(), props: RunProps::default(), kind: RunKind::Text }
}
pub fn with(text: impl Into<String>, props: RunProps) -> Self {
Run { text: text.into(), props, kind: RunKind::Text }
}
/// The characters this run contributes to the paragraph (a break is one `\u{b}`).
pub fn content(&self) -> &str {
match self.kind {
RunKind::Break => "\u{b}",
_ => &self.text,
}
}
pub fn char_len(&self) -> usize {
self.content().chars().count()
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Paragraph {
/// Outline level 0–8.
pub level: u8,
#[serde(skip_serializing_if = "is_default_para")]
pub props: ParaProps,
pub runs: Vec<Run>,
/// `a:defRPr`: defaults for runs in this paragraph that omit a property. Sits above the
/// placeholder's list style, so a layout can still supply what this paragraph leaves unset.
#[serde(skip_serializing_if = "is_default_props")]
pub def_props: RunProps,
/// Properties of the paragraph end mark (used for an empty paragraph's caret).
#[serde(skip_serializing_if = "is_default_props")]
pub end_props: RunProps,
}
fn is_default_para(p: &ParaProps) -> bool {
*p == ParaProps::default()
}
impl Paragraph {
pub fn new(text: &str) -> Self {
Paragraph { runs: if text.is_empty() { vec![] } else { vec![Run::new(text)] }, ..Default::default() }
}
/// Leading tabs are the outline level (at most 8), the same rule as a typed indent.
/// Returns whether any tab was consumed.
pub fn promote_leading_tabs(&mut self) -> bool {
let Some(r) = self.runs.first_mut() else { return false };
if r.kind != RunKind::Text {
return false;
}
let tabs = r.text.chars().take_while(|c| *c == '\t').count();
if tabs == 0 {
return false;
}
r.text = r.text.trim_start_matches('\t').to_string();
self.level = tabs.min(8) as u8;
true
}
pub fn text(&self) -> String {
self.runs.iter().map(Run::content).collect()
}
pub fn char_len(&self) -> usize {
self.runs.iter().map(Run::char_len).sum()
}
pub fn is_empty(&self) -> bool {
self.char_len() == 0
}
/// Merge adjacent text runs with identical properties.
pub fn normalize(&mut self) {
let mut out: Vec<Run> = Vec::with_capacity(self.runs.len());
for r in self.runs.drain(..) {
if r.kind == RunKind::Text && r.text.is_empty() {
continue;
}
if let Some(last) = out.last_mut()
&& last.kind == RunKind::Text
&& r.kind == RunKind::Text
&& last.props == r.props
{
last.text.push_str(&r.text);
continue;
}
out.push(r);
}
self.runs = out;
}
}
/// Per-level paragraph + run defaults (`lstStyle`, master text styles, `defaultTextStyle`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LevelStyle {
pub para: ParaProps,
pub run: RunProps,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ListStyle {
/// Levels 1–9 (index 0–8); `None` = not specified.
pub levels: Vec<Option<LevelStyle>>,
}
impl ListStyle {
pub fn level(&self, l: u8) -> Option<&LevelStyle> {
self.levels.get(l as usize).and_then(Option::as_ref)
}
pub fn set(&mut self, l: u8, s: LevelStyle) {
let i = (l as usize).min(8);
if self.levels.len() <= i {
self.levels.resize(i + 1, None);
}
if let Some(slot) = self.levels.get_mut(i) {
*slot = Some(s);
}
}
pub fn is_empty(&self) -> bool {
self.levels.iter().all(Option::is_none)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TextBody {
pub body: BodyProps,
#[serde(skip_serializing_if = "ListStyle::is_empty")]
pub list_style: ListStyle,
pub paragraphs: Vec<Paragraph>,
}
impl TextBody {
pub fn from_text(text: &str) -> Self {
let text = text.replace("\r\n", "\n").replace('\r', "\n");
TextBody { paragraphs: text.split('\n').map(Paragraph::new).collect(), ..Default::default() }
}
/// Plain text with `\n` between paragraphs.
pub fn text(&self) -> String {
self.paragraphs.iter().map(Paragraph::text).collect::<Vec<_>>().join("\n")
}
pub fn is_empty(&self) -> bool {
self.paragraphs.iter().all(Paragraph::is_empty)
}
/// Total characters, counting one separator between paragraphs.
pub fn char_len(&self) -> usize {
let n: usize = self.paragraphs.iter().map(Paragraph::char_len).sum();
n + self.paragraphs.len().saturating_sub(1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::Rgba;
#[test]
fn alternate_language_inherits_and_native_input_is_bounded() {
let mut child = RunProps::default();
child.inherit(&RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() });
assert_eq!(child.lang.as_deref(), Some("en-US"));
assert_eq!(child.alt_lang.as_deref(), Some("fr-FR"));
let oversized = "x".repeat(300);
let json = serde_json::json!({"altLang": oversized});
let props: RunProps = serde_json::from_value(json).expect("run props");
assert_eq!(props.alt_lang.as_deref().map(str::len), Some(256));
assert_eq!(serde_json::from_value::<RunProps>(serde_json::json!({})).expect("old run props"), RunProps::default());
}
#[test]
fn explicit_bullet_follow_text_resets_block_inheritance() {
let parent = ParaProps {
bullet_font: Some("Inter".into()),
bullet_color: Some(ColorRef::rgb(Rgba::rgb(0, 0, 0))),
bullet_size: Some(2.0),
..Default::default()
};
let mut child = ParaProps {
bullet_font_follow_text: Some(true),
bullet_color_follow_text: Some(true),
bullet_size_follow_text: Some(true),
..Default::default()
};
child.inherit(&parent);
assert_eq!(child.bullet_font, None);
assert_eq!(child.bullet_color, None);
assert_eq!(child.bullet_size, None);
let mut inherited_reset = ParaProps::default();
inherited_reset.inherit(&ParaProps {
bullet_font_follow_text: Some(true),
bullet_color_follow_text: Some(true),
bullet_size_follow_text: Some(true),
..Default::default()
});
assert_eq!(inherited_reset.bullet_font_follow_text, Some(true));
assert_eq!(inherited_reset.bullet_color_follow_text, Some(true));
assert_eq!(inherited_reset.bullet_size_follow_text, Some(true));
let mut explicit_false = ParaProps {
bullet_font_follow_text: Some(false),
bullet_color_follow_text: Some(false),
bullet_size_follow_text: Some(false),
..Default::default()
};
explicit_false.inherit(&parent);
assert_eq!(explicit_false.bullet_font, parent.bullet_font);
assert_eq!(explicit_false.bullet_color, parent.bullet_color);
assert_eq!(explicit_false.bullet_size, parent.bullet_size);
}
}
+766
View File
@@ -0,0 +1,766 @@
//! Least-squares trendlines: linear, exponential, logarithmic, polynomial, power, and a
//! moving average. The fit is what the chart drawer plots and what the axis grows to include.
use crate::chart::{ChartType, Series, Trendline, TrendlineType};
/// A fitted trendline. `y` is `None` where the model is undefined (a log of a non-positive x,
/// an exponential that overflowed).
#[derive(Clone, Debug)]
pub struct TrendFit {
kind: TrendlineType,
model: Model,
r2: f64,
x_min: f64,
x_max: f64,
}
#[derive(Clone, Debug)]
enum Model {
/// `y = c[0] + c[1] x + c[2] x² + …`
Poly(Vec<f64>),
/// `y = c · e^(b x)`
Exp { c: f64, b: f64 },
/// `y = b + c · ln(x)`
Log { b: f64, c: f64 },
/// `y = c · x^b`
Power { c: f64, b: f64 },
/// Window averages in x order. No equation.
Moving(Vec<(f64, f64)>),
}
impl Trendline {
/// Fit `xs`/`ys` (paired). Non-finite pairs are dropped. `None` when the type needs more
/// points than it was given, or the normal equations have no solution.
pub fn fit(&self, xs: &[f64], ys: &[f64]) -> Option<TrendFit> {
let mut pairs: Vec<(f64, f64)> =
xs.iter().zip(ys).map(|(x, y)| (*x, *y)).filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect();
if pairs.len() < 2 {
return None;
}
pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let (x_min, x_max) = (pairs.first().map(|p| p.0)?, pairs.last().map(|p| p.0)?);
let intercept = self.intercept.filter(|v| v.is_finite());
let (model, used_y, yhat) = match self.kind {
TrendlineType::Linear => {
let (xs, ys) = split(&pairs);
let (b, m) = linreg(&xs, &ys, intercept)?;
let yhat: Vec<f64> = xs.iter().map(|x| b + m * x).collect();
(Model::Poly(vec![b, m]), ys, yhat)
}
TrendlineType::Poly => {
let order = self.order.unwrap_or(2).clamp(2, 6) as usize;
if pairs.len() < order + 1 {
return None;
}
let (xs, ys) = split(&pairs);
let coeff = polyfit(&xs, &ys, order, intercept)?;
let yhat: Vec<f64> = xs.iter().map(|x| poly_y(&coeff, *x)).collect();
(Model::Poly(coeff), ys, yhat)
}
TrendlineType::Exp => fit_exp(&pairs, intercept)?,
TrendlineType::Log => {
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, _)| *x > 0.0).collect();
if pairs.len() < 2 {
return None;
}
let (ln_x, ys): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), *y)).unzip();
let (b, c) = linreg(&ln_x, &ys, intercept)?;
let yhat: Vec<f64> = ln_x.iter().map(|u| b + c * u).collect();
(Model::Log { b, c }, ys, yhat)
}
TrendlineType::Power => fit_power(&pairs, intercept)?,
TrendlineType::MovingAvg => {
let period = self.period.unwrap_or(2).clamp(2, 255) as usize;
fit_moving_avg(&pairs, period)?
}
};
if yhat.iter().any(|v| !v.is_finite()) {
return None;
}
// A moving average starts at the first full window, not at the first data point.
let (x_min, x_max) = if let Model::Moving(pts) = &model {
match (pts.first(), pts.last()) {
(Some(a), Some(b)) => (a.0, b.0),
_ => (x_min, x_max),
}
} else {
(x_min, x_max)
};
Some(TrendFit { kind: self.kind, model, r2: r_squared(&used_y, &yhat), x_min, x_max })
}
/// The equation and R² lines this trendline asks to show. Empty when neither is on. A moving
/// average has no equation, and PowerPoint does not show its R², so that label is left off
/// even when the file asks for it.
pub fn caption(&self, fit: &TrendFit) -> String {
let mut lines = Vec::new();
if self.disp_eq
&& let Some(eq) = fit.equation()
{
lines.push(eq);
}
if self.disp_r_sqr && self.kind != TrendlineType::MovingAvg {
lines.push(fit.r2_label());
}
lines.join("\n")
}
}
impl TrendFit {
pub fn y(&self, x: f64) -> Option<f64> {
if !x.is_finite() {
return None;
}
let y = match &self.model {
Model::Poly(c) => poly_y(c, x),
Model::Exp { c, b } => c * (b * x).exp(),
Model::Log { b, c } => {
if x <= 0.0 {
return None;
}
b + c * x.ln()
}
Model::Power { c, b } => {
if x <= 0.0 {
return None;
}
c * x.powf(*b)
}
Model::Moving(_) => return None,
};
y.is_finite().then_some(y)
}
/// Samples from `x0` to `x1`, forecast included. A moving average returns its window points
/// and ignores the forecast (Excel does not extend one).
pub fn samples(&self, x0: f64, x1: f64) -> Vec<(f64, f64)> {
let (x0, x1) = if x0 <= x1 { (x0, x1) } else { (x1, x0) };
if !x0.is_finite() || !x1.is_finite() {
return Vec::new();
}
if let Model::Moving(pts) = &self.model {
return pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect();
}
if matches!(self.kind, TrendlineType::Linear) {
return [x0, x1].into_iter().filter_map(|x| self.y(x).map(|y| (x, y))).collect();
}
let steps = 48;
(0..=steps)
.filter_map(|i| {
let x = x0 + (x1 - x0) * i as f64 / steps as f64;
self.y(x).map(|y| (x, y))
})
.collect()
}
/// Data x plus forecast, in category (or scatter x) units. Forecast is capped so a hostile
/// value cannot walk off the chart.
pub fn span(&self, forward: Option<f64>, backward: Option<f64>) -> (f64, f64) {
let pad = |v: Option<f64>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(24.0)).unwrap_or(0.0);
if matches!(self.kind, TrendlineType::MovingAvg) { (self.x_min, self.x_max) } else { (self.x_min - pad(backward), self.x_max + pad(forward)) }
}
pub fn equation(&self) -> Option<String> {
let body = match &self.model {
Model::Poly(c) => poly_equation(c),
Model::Exp { c, b } => Some(format!("y = {}e^({}x)", num(*c), num(*b))),
Model::Log { b, c } => poly_equation(&[*b, *c]).map(|s| s.replacen("x", "ln(x)", 1)),
Model::Power { c, b } => {
let mag = if (c.abs() - 1.0).abs() < 1e-6 { String::new() } else { num(c.abs()) };
let sign = if *c < 0.0 { "-" } else { "" };
Some(format!("y = {sign}{mag}{}", x_pow(*b)))
}
Model::Moving(_) => None,
}?;
Some(body)
}
pub fn r2_label(&self) -> String {
let r = if self.r2.is_finite() { self.r2 } else { 0.0 };
if (r - 1.0).abs() < 5e-4 { "R² = 1".to_string() } else { format!("R² = {r:.3}") }
}
}
impl Series {
/// Trendline y values, including a short forecast, that sit near this series. The axis uses
/// them so a forecast is not clipped off the plot. A value many times the series' own range
/// is left out, so a wild polynomial cannot flatten the chart.
pub fn trendline_ys(&self, scatter: bool) -> Vec<f64> {
let n = self.values.len().min(10_000);
let mut xs = Vec::new();
let mut ys = Vec::new();
for i in 0..n {
let Some(y) = self.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
let x = if scatter {
let Some(x) = self.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
x
} else {
(i + 1) as f64
};
xs.push(x);
ys.push(y);
}
let (data_lo, data_hi) = ys.iter().fold((f64::MAX, f64::MIN), |(lo, hi), y| (lo.min(*y), hi.max(*y)));
if !data_lo.is_finite() || !data_hi.is_finite() {
return Vec::new();
}
let span = (data_hi - data_lo).abs().max(1.0);
let mid = (data_lo + data_hi) / 2.0;
let mut out = Vec::new();
for t in self.trendlines.iter().take(8) {
let Some(fit) = t.fit(&xs, &ys) else { continue };
let (x0, x1) = fit.span(t.forward, t.backward);
for (_, y) in fit.samples(x0, x1) {
if y.is_finite() && (y - mid).abs() <= span * 8.0 {
out.push(y);
}
}
}
out
}
}
/// Whether a series is plotted against numeric x values rather than category positions.
pub fn series_is_scatter(kind: ChartType) -> bool {
matches!(kind, ChartType::Scatter | ChartType::Bubble)
}
fn split(pairs: &[(f64, f64)]) -> (Vec<f64>, Vec<f64>) {
pairs.iter().map(|(x, y)| (*x, *y)).unzip()
}
/// `y = b + m x`. `intercept` fixes `b`.
fn linreg(xs: &[f64], ys: &[f64], intercept: Option<f64>) -> Option<(f64, f64)> {
if xs.len() != ys.len() || xs.len() < 2 {
return None;
}
if let Some(b) = intercept {
let mut sxx = 0.0;
let mut sxy = 0.0;
for (x, y) in xs.iter().zip(ys) {
sxx += x * x;
sxy += x * (y - b);
}
if sxx <= 0.0 || !sxx.is_finite() {
return None;
}
let m = sxy / sxx;
return (m.is_finite() && b.is_finite()).then_some((b, m));
}
let n = xs.len() as f64;
let mx = xs.iter().sum::<f64>() / n;
let my = ys.iter().sum::<f64>() / n;
let (mut sxx, mut sxy) = (0.0, 0.0);
for (x, y) in xs.iter().zip(ys) {
let dx = x - mx;
sxx += dx * dx;
sxy += dx * (y - my);
}
// Constant x with a large offset leaves rounding residue in `mx`, so compare against the
// rounding scale of the mean instead of zero.
if sxx <= 4.0 * n * (f64::EPSILON * mx.abs()).powi(2) || !sxx.is_finite() {
return None;
}
let m = sxy / sxx;
let b = my - m * mx;
(m.is_finite() && b.is_finite()).then_some((b, m))
}
fn poly_y(coeff: &[f64], x: f64) -> f64 {
let mut acc = 0.0;
let mut xp = 1.0;
for c in coeff {
acc += c * xp;
if !acc.is_finite() {
return f64::NAN;
}
xp *= x;
}
acc
}
fn fit_exp(pairs: &[(f64, f64)], intercept: Option<f64>) -> Option<(Model, Vec<f64>, Vec<f64>)> {
let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(_, y)| *y > 0.0).collect();
if pairs.len() < 2 {
return None;
}
let (xs, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip();
let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln);
let (ln_c, b) = linreg(&xs, &ln_y, fixed)?;
let c = ln_c.exp();
if !c.is_finite() || c <= 0.0 {
return None;
}
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
let yhat: Vec<f64> = xs.iter().map(|x| c * (b * x).exp()).collect();
Some((Model::Exp { c, b }, ys, yhat))
}
fn fit_power(pairs: &[(f64, f64)], intercept: Option<f64>) -> Option<(Model, Vec<f64>, Vec<f64>)> {
let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect();
if pairs.len() < 2 {
return None;
}
let (ln_x, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip();
let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln);
let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?;
let c = ln_c.exp();
if !c.is_finite() || c <= 0.0 {
return None;
}
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
let yhat: Vec<f64> = pairs.iter().map(|(x, _)| c * x.powf(b)).collect();
Some((Model::Power { c, b }, ys, yhat))
}
fn fit_moving_avg(pairs: &[(f64, f64)], period: usize) -> Option<(Model, Vec<f64>, Vec<f64>)> {
if pairs.len() <= period {
return None;
}
let mut pts = Vec::new();
let mut ys = Vec::new();
let mut yhat = Vec::new();
for i in period - 1..pairs.len() {
let window = &pairs[i + 1 - period..=i];
let avg = window.iter().map(|(_, y)| y).sum::<f64>() / period as f64;
if avg.is_finite() {
pts.push((pairs[i].0, avg));
ys.push(pairs[i].1);
yhat.push(avg);
}
}
if pts.len() < 2 {
return None;
}
Some((Model::Moving(pts), ys, yhat))
}
#[allow(clippy::needless_range_loop)]
fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option<Vec<f64>> {
// Fit `y - b0 = (x / sx) * q(u)` with `u = (x - xm) / su` and `q` of degree `order - 1`. The
// basis `(x / sx) * u^j` spans the same polynomials as `x^(j + 1)`, but keeps the pivots in
// `solve` independent of the units and the offset of x.
let n = xs.len() as f64;
let sx = xs.iter().fold(0.0_f64, |acc, x| acc.max(x.abs()));
if sx <= 0.0 || !sx.is_finite() {
return None;
}
let xm = xs.iter().sum::<f64>() / n;
let su = match xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs())) {
s if s > 0.0 && s.is_finite() => s,
_ => 1.0,
};
let m = order;
let mut ata = vec![vec![0.0; m]; m];
let mut atb = vec![0.0; m];
for (x, y) in xs.iter().zip(ys) {
let v = x / sx;
let u = (x - xm) / su;
let dy = y - b0;
for i in 0..m {
let xi = v * u.powi(i as i32);
atb[i] += dy * xi;
for j in i..m {
ata[i][j] += xi * v * u.powi(j as i32);
}
}
}
for i in 0..m {
for j in 0..i {
ata[i][j] = ata[j][i];
}
}
let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / su.powi(k as i32)).collect();
let q = expand_shift(&centred, xm);
let mut coeff = Vec::with_capacity(order + 1);
coeff.push(b0);
coeff.extend(q.iter().map(|c| c / sx));
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
}
/// Polynomial through the normal equations. `x` is centred when the intercept is free, then
/// expanded back to powers of `x`, so order 6 on category numbers stays solvable.
#[allow(clippy::needless_range_loop)] // the index is the power in the normal equations
fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Option<Vec<f64>> {
let n = xs.len();
if n != ys.len() || !(1..=6).contains(&order) || n < order + 1 {
return None;
}
if let Some(b0) = intercept {
return polyfit_with_intercept(xs, ys, order, b0);
}
let xm = xs.iter().sum::<f64>() / n as f64;
// Scale the centred x to unit range so the pivot threshold in `solve` does not depend on the
// units of x.
let s = xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs()));
if s <= 0.0 || !s.is_finite() {
return None;
}
let us: Vec<f64> = xs.iter().map(|x| (x - xm) / s).collect();
let m = order + 1;
let mut ata = vec![vec![0.0; m]; m];
let mut atb = vec![0.0; m];
for (u, y) in us.iter().zip(ys) {
for i in 0..m {
let ui = if i == 0 { 1.0 } else { u.powi(i as i32) };
atb[i] += y * ui;
for j in i..m {
let uj = if j == 0 { 1.0 } else { u.powi(j as i32) };
ata[i][j] += ui * uj;
}
}
}
for i in 0..m {
for j in 0..i {
ata[i][j] = ata[j][i];
}
}
let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / s.powi(k as i32)).collect();
let coeff = expand_shift(&centred, xm);
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
}
/// `a[k]` is the coefficient of `(x - xm)^k`. Return coefficients of powers of `x`.
#[allow(clippy::needless_range_loop)] // j is the power of x after the binomial shift
fn expand_shift(a: &[f64], xm: f64) -> Vec<f64> {
let mut c = vec![0.0; a.len()];
for (k, ak) in a.iter().enumerate() {
let mut binom = 1.0;
for j in 0..=k {
c[j] += ak * binom * (-xm).powi((k - j) as i32);
if j < k {
binom *= (k - j) as f64 / (j + 1) as f64;
}
}
}
c
}
#[allow(clippy::needless_range_loop)] // Gaussian elimination walks rows and columns by index
fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
let n = b.len();
if n == 0 || n > 8 || a.len() != n {
return None;
}
for col in 0..n {
let mut piv = col;
for r in col + 1..n {
if a[r][col].abs() > a[piv][col].abs() {
piv = r;
}
}
if a[piv][col].abs() < 1e-12 {
return None;
}
a.swap(col, piv);
b.swap(col, piv);
let div = a[col][col];
for c in col..n {
a[col][c] /= div;
}
b[col] /= div;
for r in 0..n {
if r == col {
continue;
}
let f = a[r][col];
if f == 0.0 {
continue;
}
for c in col..n {
a[r][c] -= f * a[col][c];
}
b[r] -= f * b[col];
}
}
b.iter().all(|v| v.is_finite()).then_some(b)
}
fn r_squared(ys: &[f64], yhat: &[f64]) -> f64 {
if ys.is_empty() || ys.len() != yhat.len() {
return 0.0;
}
let mean = ys.iter().sum::<f64>() / ys.len() as f64;
let mut ss_tot = 0.0;
let mut ss_res = 0.0;
for (y, h) in ys.iter().zip(yhat) {
ss_tot += (y - mean) * (y - mean);
ss_res += (y - h) * (y - h);
}
if ss_tot < 1e-18 {
return if ss_res < 1e-12 { 1.0 } else { 0.0 };
}
let r = 1.0 - ss_res / ss_tot;
if r.is_finite() { r } else { 0.0 }
}
fn num(v: f64) -> String {
if !v.is_finite() {
return "0".to_string();
}
let a = v.abs();
if a == 0.0 {
return "0".to_string();
}
if !(1.0e-3..1.0e6).contains(&a) {
return format!("{v:.2e}").replace('+', "");
}
let digits = if a >= 100.0 {
1
} else if a >= 10.0 {
2
} else {
3
};
let mut s = format!("{v:.prec$}", prec = digits);
if let Some(dot) = s.find('.') {
while s.ends_with('0') {
s.pop();
}
if s.len() == dot + 1 {
s.pop();
}
}
s
}
fn x_pow(p: f64) -> String {
if (p - 1.0).abs() < 1e-6 {
"x".to_string()
} else if (p - 2.0).abs() < 1e-6 {
"x²".to_string()
} else if (p - 3.0).abs() < 1e-6 {
"x³".to_string()
} else {
format!("x^{}", num(p))
}
}
fn poly_equation(coeff: &[f64]) -> Option<String> {
// Drop terms that are negligible relative to the largest coefficient, so tiny-valued data keeps its terms.
let scale = coeff.iter().map(|c| c.abs()).fold(0.0, f64::max);
let mut body = String::new();
let mut first = true;
for (k, c) in coeff.iter().enumerate().rev() {
if c.abs() <= scale * 1e-8 {
continue;
}
let pow = match k {
0 => String::new(),
1 => "x".to_string(),
_ => x_pow(k as f64),
};
let mag = if pow.is_empty() {
num(c.abs())
} else if (c.abs() - 1.0).abs() < scale.max(1.0) * 1e-8 {
pow
} else {
format!("{}{}", num(c.abs()), pow)
};
if first {
if *c < 0.0 {
body.push('-');
}
body.push_str(&mag);
first = false;
} else if *c < 0.0 {
body.push_str(" - ");
body.push_str(&mag);
} else {
body.push_str(" + ");
body.push_str(&mag);
}
}
if body.is_empty() {
body.push('0');
}
Some(format!("y = {body}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn line(kind: TrendlineType) -> Trendline {
Trendline { kind, ..Default::default() }
}
#[test]
fn linear_fits_tiny_x_values() {
let xs = [1e-10, 2e-10, 3e-10, 4e-10, 5e-10];
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of tiny x values");
assert!((fit.y(3e-10).unwrap() - 3.0).abs() < 1e-6);
}
#[test]
fn poly_fits_tiny_x_values() {
let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5];
let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x;
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
let fit = line(TrendlineType::Poly).fit(&xs, &ys).expect("poly fit of tiny x values");
assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9);
let big: Vec<f64> = xs.iter().map(|x| x * 1e5).collect();
let scaled = line(TrendlineType::Poly).fit(&big, &ys).expect("poly fit of scaled x values");
for x in xs {
assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}");
}
}
#[test]
fn poly_with_intercept_fits_tiny_x_values() {
let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5];
let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x;
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
let mut poly = line(TrendlineType::Poly);
poly.intercept = Some(1.0);
let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of tiny x values");
assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9);
let big: Vec<f64> = xs.iter().map(|x| x * 1e5).collect();
let scaled = poly.fit(&big, &ys).expect("poly fit with intercept of scaled x values");
for x in xs {
assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}");
}
}
#[test]
fn poly_with_intercept_fits_offset_but_narrow_x_range() {
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
let f = |x: f64| 1.0 + x * (1.0 + 100.0 * (x - 45000.02));
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
let mut poly = line(TrendlineType::Poly);
poly.intercept = Some(1.0);
let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of offset x values");
for x in xs {
assert!((fit.y(x).unwrap() - f(x)).abs() < 1e-2, "{x}");
}
}
#[test]
fn linear_fits_offset_but_narrow_x_range() {
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of offset x values");
assert!((fit.y(45000.02).unwrap() - 3.0).abs() < 1e-4);
let xs = [1e7, 1e7 + 1.0, 1e7 + 2.0, 1e7 + 3.0, 1e7 + 4.0];
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of 1e7 offset");
assert!((fit.y(1e7 + 2.0).unwrap() - 3.0).abs() < 1e-4);
}
#[test]
fn linear_fixed_intercept_on_offset_narrow_x_range() {
let xs = [1e7, 1e7 + 1.0, 1e7 + 2.0, 1e7 + 3.0, 1e7 + 4.0];
let ys: Vec<f64> = xs.iter().map(|x| 2.0 * x).collect();
let mut forced = line(TrendlineType::Linear);
forced.intercept = Some(0.0);
let fit = forced.fit(&xs, &ys).expect("fixed-intercept fit");
assert!((fit.y(1e7 + 2.0).unwrap() / (2.0 * (1e7 + 2.0)) - 1.0).abs() < 1e-9);
}
#[test]
fn linear_fixed_intercept_rejects_overflowing_x() {
let xs = [1e160, 2e160, 3e160];
let ys = [1.0, 2.0, 3.0];
let mut forced = line(TrendlineType::Linear);
forced.intercept = Some(0.0);
assert!(forced.fit(&xs, &ys).is_none());
}
#[test]
fn linear_rejects_constant_x_with_large_offset() {
let xs = [45000.1; 5];
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
assert!(line(TrendlineType::Linear).fit(&xs, &ys).is_none());
}
#[test]
fn linear_recovers_slope_and_a_fixed_intercept() {
let xs = [1.0, 2.0, 3.0, 4.0];
let ys = [3.0, 5.0, 7.0, 9.0];
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit");
assert!((fit.y(1.0).unwrap() - 3.0).abs() < 1e-9);
assert!((fit.y(4.0).unwrap() - 9.0).abs() < 1e-9);
assert_eq!(fit.equation().as_deref(), Some("y = 2x + 1"));
assert_eq!(fit.r2_label(), "R² = 1");
let mut forced = line(TrendlineType::Linear);
forced.intercept = Some(0.0);
let fit = forced.fit(&[1.0, 2.0], &[2.0, 4.0]).expect("fit");
assert!((fit.y(3.0).unwrap() - 6.0).abs() < 1e-6);
assert_eq!(fit.equation().as_deref(), Some("y = 2x"));
}
#[test]
fn exp_log_power_and_quadratic_round_their_points() {
let xs = [1.0, 2.0, 3.0, 4.0];
let exp = line(TrendlineType::Exp).fit(&xs, &[1.0, 2.0, 4.0, 8.0]).expect("exp");
for (x, y) in xs.iter().zip([1.0, 2.0, 4.0, 8.0]) {
assert!((exp.y(*x).unwrap() - y).abs() < 1e-6, "{x}");
}
assert!(exp.equation().unwrap().contains("e^("));
let log = line(TrendlineType::Log).fit(&xs, &[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()]).expect("log");
assert!((log.y(2.0).unwrap() - 2.0_f64.ln()).abs() < 1e-6);
assert!(log.equation().unwrap().contains("ln(x)"));
let power = line(TrendlineType::Power).fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("power");
assert!((power.y(3.0).unwrap() - 9.0).abs() < 1e-6);
assert_eq!(power.equation().as_deref(), Some("y = x²"));
let mut poly = line(TrendlineType::Poly);
poly.order = Some(2);
let quad = poly.fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("poly");
assert!((quad.y(3.0).unwrap() - 9.0).abs() < 1e-6);
assert_eq!(quad.equation().as_deref(), Some("y = x²"));
}
#[test]
fn moving_average_and_forecast_span() {
let fit = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), ..Default::default() }
.fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0])
.expect("ma");
let pts = fit.samples(0.0, 10.0);
assert_eq!(pts.len(), 3);
assert!((pts[0].1 - 5.0).abs() < 1e-9);
assert!((pts[1].1 - 6.0).abs() < 1e-9);
assert!((pts[2].1 - 7.0).abs() < 1e-9);
assert!(fit.equation().is_none());
let linear = line(TrendlineType::Linear).fit(&[1.0, 2.0], &[1.0, 2.0]).expect("line");
assert_eq!(linear.span(Some(1.5), Some(-3.0)), (1.0, 3.5));
assert_eq!(fit.span(Some(4.0), Some(4.0)), (2.0, 4.0));
}
#[test]
fn moving_average_shows_neither_equation_nor_r2() {
let t = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), disp_eq: true, disp_r_sqr: true, ..Default::default() };
let fit = t.fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0]).expect("ma");
assert!(fit.r2_label().starts_with("R² ="));
assert!(t.caption(&fit).is_empty(), "{}", t.caption(&fit));
}
#[test]
fn too_few_points_and_non_finite_input_fit_nothing() {
assert!(line(TrendlineType::Linear).fit(&[1.0], &[2.0]).is_none());
assert!(line(TrendlineType::Exp).fit(&[1.0, 2.0], &[-1.0, -2.0]).is_none());
assert!(line(TrendlineType::Poly).fit(&[1.0, 2.0], &[1.0, 2.0]).is_none());
assert!(line(TrendlineType::Linear).fit(&[f64::NAN, f64::INFINITY], &[1.0, 2.0]).is_none());
let mut t = line(TrendlineType::Linear);
t.disp_eq = true;
t.disp_r_sqr = true;
let fit = t.fit(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]).expect("fit");
assert_eq!(t.caption(&fit), "y = x\nR² = 1");
}
#[test]
fn equation_keeps_tiny_coefficients() {
let xs = [1.0, 2.0, 3.0, 4.0];
let lin = line(TrendlineType::Linear).fit(&xs, &[2e-9, 4e-9, 6e-9, 8e-9]).expect("linear");
assert_eq!(lin.equation().as_deref(), Some("y = 2.00e-9x"));
let mut poly = line(TrendlineType::Poly);
poly.order = Some(2);
let ys: Vec<f64> = xs.iter().map(|x| 3e-9 * x * x + 1e-9).collect();
let quad = poly.fit(&xs, &ys).expect("poly");
assert_eq!(quad.equation().as_deref(), Some("y = 3.00e-9x² + 1.00e-9"));
let ys: Vec<f64> = xs.iter().map(|x: &f64| 5e-9 * x.ln()).collect();
let log = line(TrendlineType::Log).fit(&xs, &ys).expect("log");
assert_eq!(log.equation().as_deref(), Some("y = 5.00e-9ln(x)"));
let zero = line(TrendlineType::Linear).fit(&xs, &[0.0; 4]).expect("zero");
assert_eq!(zero.equation().as_deref(), Some("y = 0"));
}
}