mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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:
1 parent
046a927171
commit
5ebc719ca2
11 files changed
+5008
-1
No files matched your search
Generated
+1
@@ -985,6 +985,7 @@ dependencies = [
|
||||
"craft-xml",
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -14,3 +14,6 @@ workspace = true
|
||||
craft-xml = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¢red, xm);
|
||||
let mut coeff = Vec::with_capacity(order + 1);
|
||||
coeff.push(b0);
|
||||
coeff.extend(q.iter().map(|c| c / sx));
|
||||
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
||||
}
|
||||
|
||||
/// Polynomial through the normal equations. `x` is centred when the intercept is free, then
|
||||
/// expanded back to powers of `x`, so order 6 on category numbers stays solvable.
|
||||
#[allow(clippy::needless_range_loop)] // the index is the power in the normal equations
|
||||
fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<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(¢red, xm);
|
||||
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
||||
}
|
||||
|
||||
/// `a[k]` is the coefficient of `(x - xm)^k`. Return coefficients of powers of `x`.
|
||||
#[allow(clippy::needless_range_loop)] // j is the power of x after the binomial shift
|
||||
fn expand_shift(a: &[f64], xm: f64) -> Vec<f64> {
|
||||
let mut c = vec![0.0; a.len()];
|
||||
for (k, ak) in a.iter().enumerate() {
|
||||
let mut binom = 1.0;
|
||||
for j in 0..=k {
|
||||
c[j] += ak * binom * (-xm).powi((k - j) as i32);
|
||||
if j < k {
|
||||
binom *= (k - j) as f64 / (j + 1) as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_range_loop)] // Gaussian elimination walks rows and columns by index
|
||||
fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
let n = b.len();
|
||||
if n == 0 || n > 8 || a.len() != n {
|
||||
return None;
|
||||
}
|
||||
for col in 0..n {
|
||||
let mut piv = col;
|
||||
for r in col + 1..n {
|
||||
if a[r][col].abs() > a[piv][col].abs() {
|
||||
piv = r;
|
||||
}
|
||||
}
|
||||
if a[piv][col].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
a.swap(col, piv);
|
||||
b.swap(col, piv);
|
||||
let div = a[col][col];
|
||||
for c in col..n {
|
||||
a[col][c] /= div;
|
||||
}
|
||||
b[col] /= div;
|
||||
for r in 0..n {
|
||||
if r == col {
|
||||
continue;
|
||||
}
|
||||
let f = a[r][col];
|
||||
if f == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for c in col..n {
|
||||
a[r][c] -= f * a[col][c];
|
||||
}
|
||||
b[r] -= f * b[col];
|
||||
}
|
||||
}
|
||||
b.iter().all(|v| v.is_finite()).then_some(b)
|
||||
}
|
||||
|
||||
fn r_squared(ys: &[f64], yhat: &[f64]) -> f64 {
|
||||
if ys.is_empty() || ys.len() != yhat.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean = ys.iter().sum::<f64>() / ys.len() as f64;
|
||||
let mut ss_tot = 0.0;
|
||||
let mut ss_res = 0.0;
|
||||
for (y, h) in ys.iter().zip(yhat) {
|
||||
ss_tot += (y - mean) * (y - mean);
|
||||
ss_res += (y - h) * (y - h);
|
||||
}
|
||||
if ss_tot < 1e-18 {
|
||||
return if ss_res < 1e-12 { 1.0 } else { 0.0 };
|
||||
}
|
||||
let r = 1.0 - ss_res / ss_tot;
|
||||
if r.is_finite() { r } else { 0.0 }
|
||||
}
|
||||
|
||||
fn num(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".to_string();
|
||||
}
|
||||
let a = v.abs();
|
||||
if a == 0.0 {
|
||||
return "0".to_string();
|
||||
}
|
||||
if !(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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user